Tidenda

Your first plugin

Build a plugin from an empty directory to a published version: a menu destination and an article action.

On this page

Build a Tidenda plugin from an empty directory to a published version: a Desk destination in the main menu, then a Who's on it action on every article. For developers who know React and TypeScript.

Before you start

  • Node.js 22.18 or later, npm and Docker. The plugin build runs on Node. The development instance runs its database, storage and sign-in services in Docker.
  • The Tidenda source repository. The host SDK (@dotvoid/void-host-sdk) and the plugin build (@dotvoid/void-plugin-build) are not published to any npm registry. Both ship as packages inside the Tidenda source repository, which approved publishers are given access to. A plugin is developed in a checkout of that repository, in its plugins directory, where npm links both packages into it. The checkout's .npmrc points at the public npm registry, which supplies everything else, so no token or registry setting is needed.
  • A development instance. The same checkout runs one. Fill in its environment file as the repository's development guide describes, run npm install and npm run dev, open http://localhost:1337, register an account and create an organisation.
  • An approved publisher name, for publishing. Building and running from source need none.

Step 1: create the package

A plugin is an npm package. In the checkout, create plugins/desk with this package.json. The voidPlugin block makes it a plugin:

{
  "name": "desk-plugin",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "voidPlugin": {
    "id": "com.example.desk",
    "name": "Desk",
    "description": "A greeting for the shift, and who is on each story.",
    "publisher": "example"
  },
  "scripts": { "build": "vite build" },
  "dependencies": { "@dotvoid/void-host-sdk": "^0.1.1" },
  "devDependencies": {
    "@dotvoid/stacked-router": "^1.2.1",
    "@dotvoid/void-plugin-build": "^0.1.0",
    "@heroui/react": "^3.2.1",
    "@tailwindcss/vite": "^4.0.8",
    "@types/react": "^19.1.12",
    "lucide-react": "^0.544.0",
    "react": "^19.1.1",
    "tailwindcss": "^4.1.13",
    "typescript": "^5.9.2",
    "vite": "^7.2.2"
  }
}
  • id names the plugin permanently. Use reverse-DNS under a domain you own.
  • publisher is your approved publisher name. The registry refuses any other.
  • version goes up with every release.

React, the component library and the router are development dependencies only. At runtime the plugin uses the application's own copies.

Add vite.config.ts, the whole build configuration:

import { defineConfig } from 'vite'
import { voidPlugin } from '@dotvoid/void-plugin-build'

export default defineConfig({ plugins: [voidPlugin()] })

And tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "skipLibCheck": true,
    "isolatedModules": true,
    "noEmit": true
  },
  "include": ["src"]
}

Run npm install at the root of the checkout to link the new package.

Step 2: a view in the main menu

Create src/DeskView.tsx, a plain React component:

import { Button } from '@heroui/react'
import type { ViewMetadata } from '@dotvoid/stacked-router'
import { useActiveTenant, useCurrentUser } from '@dotvoid/void-host-sdk'

const DeskView = () => {
  const user = useCurrentUser()
  const organisation = useActiveTenant()

  return (
    <div className='p-6 flex flex-col items-start gap-3'>
      <h1 className='text-lg font-semibold'>Hello, {user?.name ?? 'there'}</h1>
      <p className='text-sm text-muted'>You are on the desk at {organisation?.name}.</p>
      <Button size='sm'>Start the shift</Button>
    </div>
  )
}

const meta: ViewMetadata = {
  type: 'desk',
  breakpoints: [{ breakpoint: 720, minVw: 60 }]
}
DeskView.meta = meta

export default DeskView

useCurrentUser() and useActiveTenant() read the application's own state, and Button is the application's own button. meta.type identifies the view, and breakpoints says how much room it needs beside other views.

Then the entry, src/index.ts. It exports one function, register(), which the application calls once at startup:

import { CoffeeIcon } from 'lucide-react'
import {
  intentRegistry,
  MAIN_MENU,
  registerActionPresentation,
  registerMenuItem,
  registerRoute
} from '@dotvoid/void-host-sdk'
import DeskView from './DeskView'

const DESK_OPEN = 'com.example.desk.open'

export function register(): void {
  registerRoute({ path: '/desk', component: DeskView, meta: DeskView.meta })

  intentRegistry.register({
    id: 'com.example.desk',
    filter: { action: DESK_OPEN },
    route: '/desk',
    activeMatch: 'desk'
  })

  registerActionPresentation(DESK_OPEN, { label: 'Desk', icon: CoffeeIcon })

  registerMenuItem(MAIN_MENU, {
    id: 'com.example.desk',
    intent: { action: DESK_OPEN },
    order: 60
  })
}

Four calls, each with one job:

  1. registerRoute puts the view at the address /desk.
  2. intentRegistry.register registers an activity, an ActivityHandler in the SDK: a handler that answers an action by opening a view. Menus, links and the command palette never use addresses. They fire an action, here com.example.desk.open, and the activity that answers it opens its view.
  3. registerActionPresentation gives the action a label and an icon, used wherever the action appears.
  4. registerMenuItem puts the action in the main menu, after the built-in destinations.

Now run it. From the root of the checkout:

VOID_DEV_PLUGINS=plugins/desk npm run dev

The development instance loads the plugin from source, as if every organisation had enabled it. Reload the application: Desk is in the main menu, and the browser console says [plugins] com.example.desk@source: loaded. Saving a file reloads it.

Step 3: style it

Create src/styles.css:

@import "@dotvoid/void-host-sdk/plugin.css";
@source "./";

The first line makes the application's theme available. The second tells the build where the plugin's class names are. Import the stylesheet at the top of src/index.ts:

import './styles.css'

Classes the application already uses worked before. Now any utility class works, including ones only this plugin uses. Give the greeting a banner in the brand's ember colour by replacing the h1 and p in DeskView:

<div className='rounded-xl bg-linear-to-br from-ember/15 to-transparent px-5 py-4'>
  <h1 className='text-lg font-semibold text-balance'>Hello, {user?.name ?? 'there'}</h1>
  <p className='text-sm text-muted text-pretty'>
    You are on the desk at {organisation?.name}.
  </p>
</div>

Theme names such as text-muted and bg-surface follow the light and dark modes. Plugin recipes has the styling rules.

Step 4: an action on every article

Add a second view, src/WhosOnItView.tsx:

import type { ViewMetadata } from '@dotvoid/stacked-router'
import { useDocumentPresence } from '@dotvoid/void-host-sdk'

const WhosOnItView = ({ id }: { id: string }) => {
  const people = useDocumentPresence(id)

  return (
    <div className='p-6 flex flex-col gap-3'>
      <h1 className='text-lg font-semibold'>Who's on it</h1>
      {people.length === 0 && (
        <p className='text-sm text-muted'>Nobody has this article open.</p>
      )}
      <ul className='flex flex-col gap-2'>
        {people.map((person) => (
          <li key={person.id} className='flex items-center gap-2 text-sm'>
            <span
              className='size-2.5 rounded-full'
              style={{ background: person.color ?? 'gray' }}
            />
            {person.name}
          </li>
        ))}
      </ul>
    </div>
  )
}

const meta: ViewMetadata = {
  type: 'desk-whos-on-it',
  breakpoints: [{ breakpoint: 720, minVw: 40 }]
}
WhosOnItView.meta = meta

export default WhosOnItView

useDocumentPresence lists everyone who has the article open, including you, and updates as people come and go. person.color is the colour the application uses for that person everywhere.

Register it in src/index.ts. Add UsersIcon to the lucide-react import, import the view, and add to the end of register():

const WHOS_ON_IT = 'com.example.desk.whos-on-it'

registerRoute({
  path: '/desk/whos-on-it/[id]',
  component: WhosOnItView,
  meta: WhosOnItView.meta
})
intentRegistry.register({
  id: 'com.example.desk.whos-on-it',
  filter: { action: WHOS_ON_IT, types: ['core/article'] },
  route: '/desk/whos-on-it/:id'
})
registerActionPresentation(WHOS_ON_IT, { label: "Who's on it", icon: UsersIcon })

There is no menu item this time:

  • types: ['core/article'] makes it an action on articles. That alone puts Who's on it in the menu of every listed article, and in the command palette while an article is open.
  • :id in the activity's route is replaced by the article's id, and [id] in the route's path hands it to the view as its id prop.

Open an article's menu and choose Who's on it. The list opens beside the current view.

Step 5: build

In plugins/desk:

npm run build

dist/ now holds entry.js (the plugin, without the libraries the application provides), style.css (the classes it uses), a source map, and manifest.json, generated from package.json. The build stops with an error if register() is missing, if the manifest would be invalid, or if an import would bundle a second copy of a shared library.

Step 6: publish

No HTTP API accepts plugin code. Publishing runs on the platform's own server, in its API service, with that service's configuration, so the platform operator runs it with your build:

npm run plugins:publish -- /path/to/desk/dist --by "Your name"

It validates the manifest and the publisher, records a digest of every file, and prints a line that starts Published com.example.desk@1.0.0 (3 files). It refuses a version that is already published. The plugin is then listed for administrators in System settings > Plugins, as Managing plugins describes.

Step 7: the next release

  1. Raise version in package.json, to 1.1.0.
  2. Build and publish.

Organisations running Desk are offered Update to 1.1.0 in their plugin settings. Nothing changes for them until an administrator chooses it.

Why it is safe to run

A plugin runs with the signed-in user's access and nothing more, from files checked against the digests recorded when they were published. The full trust model is in Plugins.