Plugin reference
Manifest, compatibility rule, shared libraries, types, registration functions, hooks and load results.
On this page
The exact shape of a Tidenda plugin: its package and manifest, the compatibility rule, the libraries the application shares, the types, the functions a plugin registers with, and the hooks it reads from. Lookup material for plugin developers.
Every function, hook, type and constant below is imported from
@dotvoid/void-host-sdk unless stated otherwise.
Package fields
| Field | Required | Description |
|---|---|---|
version |
Yes | Semantic version of this build, such as 1.2.0. Becomes the manifest's version. |
voidPlugin.id |
Yes | Reverse-DNS, lower case, at least two segments: com.example.weather. Permanent. |
voidPlugin.name |
Yes | Shown to administrators. Up to 80 characters. |
voidPlugin.description |
No | Shown to administrators. Up to 400 characters. |
voidPlugin.publisher |
Yes | The approved publisher's name. Publishing refuses any other. |
voidPlugin.entry |
No | The source entry module. Default src/index.ts. |
Manifest
The build writes manifest.json from the package fields. The platform
validates it when a version is published and again before loading it.
{
"id": "com.example.weather",
"name": "Weather",
"description": "Forecasts beside the planning.",
"version": "1.0.0",
"publisher": "example",
"hostApi": "0.1.1",
"entry": "entry.js",
"styles": ["style.css"]
}
| Field | Description |
|---|---|
hostApi |
The application interface version the plugin was built against: the host SDK's version. |
entry |
The entry module. Always entry.js. |
styles |
Stylesheets. Present only when the plugin has styles. At most 8. |
Paths are relative, use forward slashes, and never contain ...
Compatibility
The application loads a plugin only when the plugin's hostApi is
compatible with the application's own interface version:
| Application interface | Compatible when | Example: a plugin built for 0.1.1 or 1.2.0 |
|---|---|---|
| Below 1.0 | Same minor, and the application's patch at least the plugin's. | 0.1.1 runs on 0.1.1 and 0.1.4; not on 0.1.0 or 0.2.0. |
| 1.0 and above | Same major, and the application at least as new. | 1.2.0 runs on 1.2.0 and 1.4.1; not on 1.1.0 or 2.0.0. |
Pre-release suffixes are ignored. Removing or renaming anything on this page, or upgrading a shared library incompatibly, is a new major version of the interface (below 1.0, a new minor).
Build output
| File | Description |
|---|---|
entry.js |
An ES module exporting register(). Shared libraries remain as imports by name. |
chunks/*.js |
Modules the plugin imports dynamically. |
style.css |
The plugin's utility classes, in the application's utilities layer. |
assets/* |
Images and fonts the plugin imports. |
entry.js.map |
Source map. |
manifest.json |
The manifest. |
A version may contain JavaScript, CSS, JSON, source maps, SVG, PNG, WebP and WOFF2 files. Publishing refuses anything else.
Build errors
| Error | Meaning |
|---|---|
... must export a register() function |
The entry module has no exported register. |
invalid plugin manifest: <field>: <problem> |
A package field is missing or malformed. |
"<import>" is a path into <package>, which the host shares, but not one it provides |
The import would bundle a second copy of a shared library. |
Entry module
export function register(): void
Called once at startup, before the application shows anything. It declares contributions and returns. It must not render, fetch or wait. If it throws, the plugin is left out.
Shared libraries
The application provides these modules at runtime, and the plugin uses the application's own instances. Import them by these exact names; the build leaves them out of the plugin.
| Module | Provides |
|---|---|
react, react/jsx-runtime, react-dom, react-dom/client |
React. |
@dotvoid/void-host-sdk |
The host SDK. |
@dotvoid/stacked-router |
Views and navigation. |
@heroui/react |
The component library. |
react-aria-components, @internationalized/date |
Accessible primitives, dates and locales. |
@dotvoid/textbit, slate, slate-react, @slate-yjs/core |
The rich-text editor. |
yjs, y-protocols/awareness, @hocuspocus/provider |
Live, shared documents. |
Any other module a plugin imports, such as an icon set, is bundled into the plugin.
Stylesheet
@import "@dotvoid/void-host-sdk/plugin.css";
@source "./";
plugin.css references the application's theme without emitting it:
Tailwind's default tokens, the component library's (such as surface,
muted, accent, danger), and the application's own (subtle,
accent-ink, danger-ink, warning-ink, success-ink,
field-border, ember, ember-deep, and the breakpoints 3xl to
8xl). It emits only the utility classes found in the files named by
@source.
Types
interface DocRef {
id: string
type: string // a document type, such as 'core/article'
}
type IntentScalar = string | number | boolean
type IntentValue =
| IntentScalar
| null
| IntentValue[]
| { [key: string]: IntentValue }
interface Intent {
action: string
subject?: DocRef | DocRef[]
type?: string
stack?: 'push' | 'replace'
extras?: Record<string, IntentValue>
}
interface WhenExpr {
all: Array<{
key: string
eq?: string | number | boolean
in?: Array<string | number>
}>
}
type DetailLevel = 'ref' | 'row' | 'loaded'
interface LoadedSubject {
ref: DocRef
doc: Y.Doc // from 'yjs', synced
}
interface OpResult {
ok: boolean
error?: string
perRow?: Array<{ id: string, ok: boolean, error?: string }>
data?: Record<string, IntentValue>
}
Intent
What a menu, link or view asks for: the what, never the how.
| Field | Description |
|---|---|
action |
Reverse-DNS action name. |
subject |
The document, or documents, it is about. |
type |
The document type when there is no document, as when creating one. Ignored with a subject. |
stack |
push (default) opens the view beside the current one. replace makes it the only view, as a main-menu destination does. |
extras |
Values for the handler. IntentValue only: JSON, no functions or class instances. Only scalars reach a view. |
WhenExpr
Every condition must hold. A condition with eq compares the key's
value, with in checks membership, and with neither requires true.
A key that is absent fails, so a handler stays hidden while the user's
context is still loading. Keys:
| Key | Value |
|---|---|
can.<permission> |
true when the user holds the permission in the active organisation, such as can.document:write or can.settings:manage. |
role.<role> |
true when the user holds the role: owner, admin, publisher or editor. |
workflowMode |
planning or simple. |
publisherEnabled |
true when the organisation's public site is switched on. |
Detail levels
detail states what a handler needs of each document:
| Level | The handler receives |
|---|---|
ref (default) |
DocRef: id and type. |
row |
DocRef. Reserved for a list-row projection, which the application does not supply. |
loaded |
LoadedSubject: the reference and the synced shared document. Expansions only. |
Activities and operations always receive references.
Registering contributions
Routes
registerRoute(route: {
path: string
component: ComponentType<never>
meta?: { type?: string, breakpoints?: Array<{ breakpoint: number, minVw: number }> }
}): void
| Field | Description |
|---|---|
path |
The view's address. A [name] segment matches any value and reaches the component as a string prop of that name. A built-in route with the same path wins. |
component |
The view. It renders inside an error boundary: if it throws, an "Extension failed" panel takes its place. |
meta.type |
Identifies the view, for activeMatch. |
meta.breakpoints |
From each breakpoint (pixels of window width) up, the view needs at least minVw percent of the window beside other views. |
Handlers
Activities, operations and expansions are handlers, all registered
with intentRegistry.register(handler). Each has a reverse-DNS id, a
filter, and exactly one of route, operation or expand.
Registering an id again replaces the handler.
interface IntentFilter {
action: string
types?: string[]
multiple?: boolean
detail?: DetailLevel
surfaces?: Array<'main' | 'dialog' | 'sheet' | 'panel' | 'inline'>
when?: WhenExpr
}
| Field | Description |
|---|---|
action |
The action answered. |
types |
Document types answered, such as ['core/article'], or ['*'] for every type. Omit for actions without a document. |
multiple |
Accepts several documents at once. Default false. |
detail |
See Detail levels. |
surfaces |
Activities only. Default ['main'], the view stack, where the application opens activities. |
when |
Conditions that must hold, or the handler is not offered. |
Any other field makes register throw. When several handlers answer
the same intent, an exact type beats '*', then the first registered
wins.
Activities
An activity (ActivityHandler) answers an action by opening a view.
Activities that accept a document are offered in that document's menu
and in the command palette.
interface ActivityHandler {
id: string
filter: IntentFilter
route: string
activeMatch?: string
extrasToQuery?: string[]
extrasToProps?: string[]
}
| Field | Description |
|---|---|
route |
The address to open. :id is replaced by the subject document's id. |
activeMatch |
The view's meta.type. Menus highlight their item while that view is open. |
extrasToQuery |
Extras copied to the address's query string. Scalars only. |
extrasToProps |
Extras passed to the view as props. Scalars only. |
Operations
An operation (OperationHandler) does something and reports a result,
without a view. Document menus never list operations: fire them with
useExec, or offer them through an expansion.
interface OperationHandler {
id: string
filter: IntentFilter
operation: () => Promise<{ default: OperationFn }>
}
type OperationFn = (
subjects: DocRef[],
extras: Record<string, IntentValue>,
intent: Intent
) => Promise<OpResult>
operation loads the implementation on first use, typically
() => import('./archive'). perRow in the result reports each
document when some succeed and others fail.
Expansions
An expansion (ExpansionHandler) runs when a document's menu opens
and returns the entries to add to it. It must declare
detail: 'loaded' and must not declare multiple.
interface ExpansionHandler {
id: string
filter: IntentFilter
expand: () => Promise<{ default: ExpandFn }>
}
type ExpandFn = (
subject: LoadedSubject,
context: Record<string, string | number | boolean | undefined>
) => Promise<Array<{ intent: Intent, label: string }>>
context holds the keys listed under WhenExpr. An
expansion that fails, or takes longer than 1.5 seconds, adds nothing.
Presentation
registerActionPresentation(action: string, presentation: {
label: string
icon?: IconComponent
order?: number
subjectIcon?: boolean
}): void
registerTypePresentation(type: string, presentation: {
label: string
icon?: IconComponent
}): void
| Field | Description |
|---|---|
label |
Sentence case. An organisation may rename it. |
icon |
See Icons. |
order |
Position among actions in a menu; lower first. Default 100. |
subjectIcon |
Show the subject's type icon when the action has no icon of its own. |
Menus
registerMenuItem(menu: string, item: {
id: string
intent: Intent
order?: number
placement?: 'start' | 'end'
group?: string
when?: WhenExpr
}): void
| Menu | Description |
|---|---|
MAIN_MENU |
The main navigation. Built-in destinations use order 10 to 50. |
SETTINGS_MENU |
The System settings sidebar. group is organisation, system or workspace. |
| Field | Description |
|---|---|
id |
Reverse-DNS. Organisations arrange and hide menu items by it. |
intent |
Fired when the item is chosen. |
order |
Lower first. Default 100. |
placement |
end pins the item after the others. Default start. |
group |
The section, for menus that have sections. |
when |
Hides the item even though its destination is reachable. Rarely needed. |
An item shows its action's label and icon, and appears only when its intent opens something for the current user.
Icons
type IconComponent = ComponentType<{
className?: string
size?: number | string
strokeWidth?: number | string
}>
An icon draws on a 24-unit grid in currentColor and honours size
and strokeWidth. Every lucide-react icon qualifies.
Actions the application answers
Constants for the application's own actions are exported by
@dotvoid/void-host-sdk:
import { DOC_OPEN, DOC_TRANSITION } from '@dotvoid/void-host-sdk'
| Constant | Does |
|---|---|
DOC_OPEN |
Opens a document in its editor or viewer. |
DOC_CREATE |
Creates a document of the intent's type and opens it. |
DOC_TRANSITION |
Requests a workflow status change to extras.to, one document per intent. ok means the request was sent. |
PLANNINGS_OVERVIEW, NEWSROOM_OPEN, NEWSWIRES_OPEN, MEDIA_BROWSE, PUBLISHER_OPEN, SETTINGS_OPEN |
Open the application's main destinations. |
Hooks
Who and where
useCurrentUser(): { id: string, name?: string, email?: string, roles?: readonly string[] } | null
useActiveTenant(): { id: string, name: string } | null
useCan(permission: string): boolean
null means signed out or still loading. roles and useCan are for
deciding what to show; the server checks the same permissions before
acting. A plugin never receives the user's credentials.
Presence
useDocumentPresence(documentId: string): Array<{
id: string
name: string
email: string
color?: string
}>
useReportPresence(documentId: string, visible: boolean): void
useDocumentPresence lists everyone with the document visibly open,
the current user included, once per person, and updates live. color
is the CSS colour the application shows for that person everywhere,
derived from their id. It may be absent; fall back to a neutral
colour. useReportPresence marks the document as open by the current
user while visible is true and the component is mounted.
Actions
useExec(): (intent: Intent) => Promise<OpResult | void>
useResolveIntents(subject: DocRef | DocRef[] | null, action?: string): Array<{ intent: Intent, handler: Handler }>
useExec returns a function that fires an intent: an activity opens
its view and resolves with nothing; an operation resolves with its
result. An intent no handler answers logs no handler for "<action>"
and resolves with nothing.
Documents
useDocumentSubscription<T>(type: string): {
documents: Array<{ id: string, content: T, createdAt?: string, charCount?: number | null }>
connected: boolean
initialised: boolean
}
useDocumentStatuses(type: string, ids: readonly string[]): {
statuses: Map<string, {
status?: string
publishedVersion?: number
statusIcon?: string
statusColor?: string
statusAiBound: boolean
}>
connected: boolean
}
useDocumentSubscription keeps a live list of the organisation's
documents of one type; content has the shape described in
Documents. initialised is true once the first
list has arrived, so an empty list can be told apart from one still
loading. useDocumentStatuses keeps the workflow status of the given
documents live. A missing entry means the document does not exist.
Live documents
useYDocument<T, V>(name: string, options?: { visibility?: boolean }): {
document: T
synced: boolean
isReady: boolean
transact: (change: (map: Y.Map<T>, transaction: Y.Transaction) => void) => void
}
useYValue<V>(container: Y.Map<unknown> | undefined, path: string): [V | undefined, (value: V) => void]
useYDocument opens a shared document by its id (typically
useYDocument<Y.Map<unknown>, never>(id)) and keeps it in sync
with every other user; the application holds the connection.
visibility (default true) reports the user as present on it; pass
false to read in the background. useYValue reads and writes one
value at a dotted path, such as useYValue<string>(doc.document, 'meta.status').
Other exports
| Area | Exports |
|---|---|
| The organisation | useTenantSettings(), useOrgFormatting(), useTimeZone(), useLocale(). |
| Workflow | useWorkflow(ydoc): a document's status and the transitions open to the user. WorkflowChip takes an optional beforeTransition(transition) check that runs when the user picks a transition and resolves to { proceed: false } to drop it, or { proceed: true, confirmed?, onTransition? }; confirmed skips the transition's own confirmation, onTransition runs just before the request is sent. |
| Uploads | useUploadActions(), useUploadQueue(): the application's upload queue. |
| Menus and labels | useMenu(menu), usePresentation(). |
| Components | View, Collection, Status, EmptyState, Icon, DatePicker, WorkflowChip, RichTextView, DocumentContextMenu, cn. |
| Side pane | View.SidePane, View.SidePaneToggle and View.SidePaneHeader, with useSidePane({ tabs }) and useSidePaneLayout(): supporting material on the right of a view's content. The pane docks beside the content from 56rem of view width, below it from 40rem, and is a bottom sheet under that. Its open tab is the view's pane query parameter. |
| Extension points | registerExtension(point, id, contribution), useExtensions(point). ARTICLE_EDITOR_CONTENT_TYPES adds content types to the article editor. |
| Dates | todayInTimezone(), addDays(), and week helpers for the organisation's calendar. |
Load results
At startup the browser console has one line per plugin:
[plugins] <id>@<version>: loaded
[plugins] <id>@<version>: skipped - <reason>
[plugins] <id>@<version>: failed - <reason>
A plugin run from source shows source as its version.
| Reason | Meaning |
|---|---|
built for host API <x>, this host is <y> |
Not compatible. Not downloaded. |
loaded from source instead |
A development instance runs the same plugin from source. |
entry answered <status> |
The entry could not be fetched. |
entry does not match its published integrity digest |
The entry differs from the published file. Not run. |
stylesheet <path> did not load or failed its digest |
A stylesheet failed. Not run. |
entry exports no register() |
No register export. |
| Any other message | register() threw it. |
Plugins not loaded within 5 seconds are left out of that session, with
[plugins] gave up after 5000 ms.
Related pages
- Your first plugin: a complete plugin, step by step.
- Plugin recipes: settings sections, operations, releases and troubleshooting.
- Plugins: how plugins are trusted, published and enabled.