Plugin interface
A plugin brings views of its own and hooks into existing ones. Both run through a single file: frontend/plugin.manifest.ts.
The manifest
The file exports an object. Every key is optional — a plugin without an interface leaves the file out entirely.
import type { OctibizPluginManifest } from '@/plugins/manifest'
const manifest: OctibizPluginManifest = {
routes: [
{
path: 'acme-shipping',
name: 'acme-shipping',
component: () => import('./views/ListView.vue'),
meta: { permission: 'acme.shipping.view' },
},
],
routeModules: { 'acme-shipping': 'acme-shipping' },
localeLoaders: {
de: () => import('./locales/de'),
en: () => import('./locales/en'),
},
}
export default manifest
The file is found at build time, from three sources: plugins in the source tree, those installed as a package, and those pulled in through the package manager. All three are merged into the same interface.
Four rules that are not negotiable
1. Core building blocks only through @/sdk. That is the stable surface. Deep imports into other core modules are rejected, because they break at the next rebuild. Additionally allowed are pure type imports of the manifest surface.
2. Components always as lazy loaders, that is () => import('./views/FooView.vue'). A direct import pulls the component into every user's initial bundle, even if they never open the module.
3. When merging, the first one wins. The core before the plugins, plugins in a stable order. A conflict produces a warning, not an abort: a faulty plugin must never take the interface apart.
4. No look of your own. There is a binding component catalogue. A plugin that defines its own buttons looks wrong after two system updates.
The most important keys
| Key | What for |
|---|---|
routes | Pages of your own, paths without a leading slash |
nav | A new navigation group |
navLinks | A link into an existing group, including the core's |
routeModules | Module lock for your own routes |
settingsTiles | Tile in the settings |
widgets | Building block for the home dashboard |
viewExtensions | Contributions to extension points in foreign views |
columnExtensions | Additional columns in foreign lists |
filterExtensions | Additional filters in foreign lists |
viewOverrides | Replace or hide a view |
searchTypes | Objects of your own in the global search |
tagScopes | Objects of your own as a target for tags |
localeLoaders | Translations, see Translations |
portalRoutes, portalNav | Contributions to the customer portal |
The interface's module lock
routeModules: { 'acme-shipping': 'acme-shipping' }
On the left stands the route prefix, on the right the short name of the module switch without module..
Mixing up those two is a real mistake, and one that has become expensive. If the right-hand value does not hit an actual switch, it never appears in the list of disabled modules — and the lock therefore always considers the module enabled. It then never takes effect.
Extension points
The core renders named slots to which plugins attach contributions. Without a contribution the slot renders nothing.
In the code these are called slots: you will meet PluginSlot, the slot registry and slot names such as customer-detail.tabs. Same thing, two words.
viewExtensions: {
'customer-detail.tabs': [
{
id: 'acmeShipping.customerShipments',
component: () => import('./components/CustomerShipmentsTab.vue'),
permission: 'acme.shipping.view',
module: 'acme-shipping',
order: 40,
meta: { labelKey: 'acmeShipping.tab.shipments' },
},
],
}
Every contribution is locked twice: through the permission and through the module switch. Both fail-closed. A disabled plugin disappears from the interface without the core view knowing about it.
The names follow a fixed scheme: <object>-detail.actions, .tabs, .panels for detail views, <object>-list.actions for lists. The complete list of all 84 slots with the values they hand over is in the extension point reference. It is generated from the code and therefore cannot go stale.
Columns, row actions, filters, form fields
Lists and dialogues have slots of their own with contracts of their own:
- Columns through
columnExtensions, the row is handed over. - Actions per row through
viewExtensionson<list>.row-actions. - Filters through
filterExtensions. The value flows into the same filter structure as a core
filter.
- Form fields through
viewExtensionson<object>-form.fields. The contribution reads and
writes the form model directly.
Replacing instead of extending
viewOverrides: {
'customer-detail.panels': { mode: 'hide' },
}
An override is authoritative: it displaces every additive contribution at that slot. replace swaps in a component of your own, hide blanks it out.
That is a sharp tool. Two plugins that override the same slot do not get along — here again the first one wins.
The customer portal is locked differently
Portal contributions run through slots of their own and without the lock through permission and module. The portal has a login of its own; it knows neither the staff's permissions nor the list of disabled modules.
There the contribution locks itself: it calls its own endpoint and hides silently if that returns nothing. With the module disabled the endpoint answers with 404 anyway.
The stable interface surface
Everything a plugin needs from the core comes out of @/sdk: building blocks, form elements, data access, permission check, notifications, formatting.
Whatever is not in there is deliberately not part of the promise. If something is missing, that is a reason to extend the surface, not a reason for a deep import.
Load translations lazily, do not import them
localeLoaders: {
de: () => import('./locales/de'),
en: () => import('./locales/en'),
}
Not as a direct import. A measurement showed that static language files contributed 651 KB compressed to the first render, for languages most users never see. Details in Translations.
What goes wrong, and why
| Symptom | Cause |
|---|---|
| The lock never takes effect, the module always counts as on | In routeModules the right-hand side holds the route segment instead of the switch short name |
| The contribution does not appear | Permission missing, module off, or an override displaces it |
| The initial bundle is suddenly large | Component or language file imported directly instead of loaded lazily |
| The build aborts with a rejected import | Deep access into a core module instead of going through @/sdk |
| After a system update the plugin looks out of place | Output elements of your own instead of the catalogue's building blocks |
Next
- Extension point reference — all 84 slots with their values
- Manifest reference — every key in detail
- Translations
- Backend — the other side