Tidenda

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