Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ The table below is generated from `apps/web/content/docs.manifest.json` by `pnpm
| Configuration & guides | [`guides/operations/web-push.md`](./docs/guides/operations/web-push.md) | Notifications that reach a member who does not have the board open, and the manifest that makes the board installable — what it costs their privacy, and how to turn it on. |
| Configuration & guides | [`guides/operations/scaling.md`](./docs/guides/operations/scaling.md) | Running more than one web container: the Redis cache that keeps them coherent, what already scales, and the step-by-step migration from a single-instance board. |
| Configuration & guides | [`guides/operations/demo-mode.md`](./docs/guides/operations/demo-mode.md) | The self-resetting public demo board that runs at demo.meith.dev — what it changes, and how to run one yourself. |
| Customization | [`customization/first-plugin.md`](./docs/customization/first-plugin.md) | The walkthrough from an empty directory to a plugin running inside a board and listed on the marketplace: scaffold, change a hook, test it, install it, publish it. |
| Customization | [`customization/themes.md`](./docs/customization/themes.md) | How to write a theme, what a theme may do, and what the API freeze covers. |
| Customization | [`customization/plugins.md`](./docs/customization/plugins.md) | What a plugin is, what it may and may not do, and how a failure is contained. |
| Customization | [`customization/marketplace.md`](./docs/customization/marketplace.md) | The curated, reviewed feed of plugins and themes: the listing schema, the review bar, the trust it does and does not extend, and how to submit or remove one. |
Expand Down
9 changes: 9 additions & 0 deletions apps/web/content/docs.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,15 @@
"primary": false,
"group": "Operating the server"
},
{
"slug": "first-plugin",
"file": "customization/first-plugin.md",
"section": "customization",
"title": "Write your first plugin",
"blurb": "The walkthrough from an empty directory to a plugin running inside a board and listed on the marketplace: scaffold, change a hook, test it, install it, publish it.",
"generated": false,
"primary": false
},
{
"slug": "themes",
"file": "customization/themes.md",
Expand Down
3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ This directory is the source for [meith.dev/docs](https://www.meith.dev/docs). C
| See what Meith is | [Introduction](./getting-started/introduction.md) |
| Run a board on your machine | [Quickstart](./getting-started/quickstart.md) |
| Put a board on your own server | [Deployment](./getting-started/deployment/index.md) |
| Build a theme or plugin | [Themes](./customization/themes.md) · [Plugins](./customization/plugins.md) |
| Build a theme or plugin | [Write your first plugin](./customization/first-plugin.md) · [Themes](./customization/themes.md) · [Plugins](./customization/plugins.md) |
| Run an existing board from the browser | [Organiser guide](./guides/community/organiser-guide.md) |
| Contribute code | [Development](./contributing/development.md) |

Expand Down Expand Up @@ -55,6 +55,7 @@ Operating the server:

## Customization

- [Write your first plugin](./customization/first-plugin.md) — the walkthrough from an empty directory to a plugin running inside a board and listed on the marketplace.
- [Themes](./customization/themes.md) — theme slots, view models, and packaging.
- [Plugins](./customization/plugins.md) — plugin boundaries, typed hooks, lifecycle, and crash isolation.
- [The marketplace](./customization/marketplace.md) — the curated feed of plugins and themes, and the listing-by-PR process.
Expand Down
180 changes: 180 additions & 0 deletions docs/customization/first-plugin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# Write your first plugin

This is the walkthrough: from an empty directory to a plugin running inside
a board and submitted to the marketplace, with a working extension at every
step. The policy — what a plugin may and may not do, and what the
guarantees cover — lives in [Plugins](./plugins.md); every hook and payload
is in the generated [Plugin hooks](../reference/plugin-hooks.md) reference.
This page assumes both exist and shows the path through them.

## Scaffold it

```sh
npx create-meith --plugin first-light
cd first-light
npm install
npm test
```

That is already a complete, passing extension. The scaffold's source is
generated from the meith repository's `examples/hello-plugin` — reviewed,
CI-covered code, renamed for you — so what you start from is the worked
example, not a boilerplate that drifted from it. It contains:

- `src/plugin.tsx` — the plugin: one `definePlugin` call declaring a
setting, a migration, a task, an admin page, a region contribution and
two hooks.
- `src/plugin.test.ts` — a passing test driving the `view.footer` hook the
way the board will.
- `src/index.ts` — the entry point, exporting the plugin under the two
fixed names (`plugin`, `messages`) the board's install path expects.
- `listing.json` — a pre-filled marketplace listing for later.
- `README.md` — a shorter copy of this walkthrough, kept with the code.

`definePlugin` validates the whole manifest at import time — a bad key, a
migration touching a table outside the plugin's namespace, a secret setting
with a shipped default all throw before anything registers. The first test
in `src/plugin.test.ts` exists to catch exactly that: if the module
imports, the manifest is valid.

## Change what it does

Open `src/plugin.tsx`. The plugin already handles two hooks:

- `view.footer` is a **filter**: what the handler returns replaces the
value, so the scaffold appends a footer link by returning a copy of the
model with one more entry.
- `post.created` is an **event**: the return value is discarded, and a
throw is isolated and logged rather than taking the page down.

Make it react to new threads instead. Replace the `post.created` entry
with:

```ts
'thread.created': (thread, context) => {
console.log(`thread ${thread.threadId} by user ${context.userId ?? 'guest'}`)
},
```

Both names come from the generated reference — if a hook is not listed
there, it does not fire. The value and context types are enforced: your
editor autocompletes `thread.threadId` because `definePlugin` knows the
payload for every hook name.

## Test it

The scaffold's test file shows the pattern: call the handler directly with
a model shaped like the reference says, and assert on what comes back.
Handlers are plain functions — no board, no database, no mocking layer:

```ts
it('appends its link without disturbing the board’s own', () => {
const filter = firstLightPlugin.hooks?.['view.footer'] as FilterHandler<'view.footer'>
const footer = {
boardTitle: 'A board',
links: [{ label: 'Contact', href: '/contact' }],
timezoneLabel: 'Europe/Dublin',
}

const filtered = filter(footer, { userId: null, isGuest: true, requestId: null })

expect(filtered.links).toHaveLength(2)
expect(footer.links).toHaveLength(1)
})
```

That last assertion is the one worth copying. A filter must return a new
value rather than mutating the one it was handed: the same model is passed
to every plugin in the chain, and one that edits in place changes what the
others see.

`npm test` runs vitest; `npm run typecheck` runs `tsc` over the same
source, and catches a payload field that does not exist before the board
would.

## Run it inside a board

Scaffold a board next to the plugin if you do not have one, then install
the plugin into it by path:

```sh
cd ..
npx create-meith my-board
cd my-board
npm install ../first-light
```

Register it in the board's `community.plugins.ts` — the comment at the top
of that file shows the shape:

```ts
import { messages as firstLightMessages, plugin as firstLightPlugin } from 'first-light'

export const INSTALLED_PLUGINS: readonly InstalledPlugin[] = [
{ key: 'first-light', enabled: true, plugin: firstLightPlugin, messages: firstLightMessages },
]
```

and mirror it in `board.plugins.json`:

```json
{ "plugins": [{ "key": "first-light", "package": "first-light", "enabled": true }] }
```

Then build, migrate and start:

```sh
npm run build
npx community migrate
npm run start
```

The scaffold ships one migration, which is why `community migrate` is in
the list — it creates the plugin's own `plugin_first_light_wave` table,
inside the namespace the host enforces. The plugin now appears under
**Admin → Plugins**: its setting is editable there, its admin page renders
under it, and its footer line is on the board index. Registration is
static on purpose — nothing scans a directory at runtime, so what the
bundler saw at build time is exactly what runs
([Plugins](./plugins.md#writing-a-plugin) explains why).

While iterating, npm installs a local directory as a symlink: edit the
plugin, rebuild the board, and the change is there — no reinstalling.

## Publish it

```sh
npm publish
```

The scaffold publishes `src/` as TypeScript source, the way every
`@meith/*` package ships. A board that installs your published package gets
the same files a path install gets. Version it honestly: the `version` in
`package.json` is what the admin panel shows and what your migration
history is recorded against.

## Submit it to the marketplace

The marketplace is a curated feed, not an open index — a listing is a pull
request against the meith repository, reviewed against the bar described in
[The marketplace](./marketplace.md). The scaffold pre-filled
`listing.json` with your plugin's key, package name and a compatibility
range; before submitting:

1. Set `repository` to your real repository URL (it starts as a
placeholder).
2. Take the screenshot the listing names and add it to
`marketplace/screenshots/`.
3. Copy `listing.json` into `marketplace/listings/<key>.json` in your pull
request and run `pnpm marketplace:gen`.

[The marketplace](./marketplace.md) documents the review checklist — what
reviewers read, what gets a listing declined, and how removal works.

## Themes take the same path

`npx create-meith --theme <name>` scaffolds the equivalent starting point
for a theme — the default board recoloured plus one slot override,
generated from `examples/iris-theme` the same way. From there,
[Themes](./themes.md) is the policy and [Theme slots](../reference/theme-slots.md)
the reference.