# Plugin system ReactPress 4.0 plugins extend **server-side business logic** (hooks, content rules, integrations) — WordPress-style extensibility without touching core. **Themes = presentation; plugins = logic.** > Built-in: SEO · auto-summary · batch WebP optimization · [install from Admin or CLI](../README.md#plugins) Modeled after WordPress: install/enable from Admin, configure via `plugin.json`, integrate with `register()` and hook subscriptions. ## Core model ``` ┌─────────────────────────────────────────────────────────────┐ │ Two sources (how packages land in runtime) │ │ local plugins/{id}/ → .reactpress/plugins/{id}/ │ │ npm npm pack spec → .reactpress/plugins/{id}/ │ (phase 2) └─────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────┐ │ Three layers │ │ plugins/ Registry — what can be installed │ │ .reactpress/plugins/ Materialized — installed + dist │ │ DB globalSetting Active — enabled list + per-plugin │ │ config │ └─────────────────────────────────────────────────────────────┘ ↓ activate ┌─────────────────────────────────────────────────────────────┐ │ Runtime HookService │ │ require(server.module) → register(hooks, ctx) │ └─────────────────────────────────────────────────────────────┘ ``` Aligns with the three-layer directory model in [`themes/README.md`](../themes/README.md) to reduce cognitive load for theme/plugin authors. ## vs themes and webhooks | Type | Extends | Process | Examples | | :---------- | :------------------------ | :------------------- | :----------------------- | | **Theme** | Visitor UI, SSR | Next.js (:3001) | Skins, article templates | | **Plugin** | Business rules, hooks | NestJS API (:3002) | Auto summary, SEO | | **Webhook** | Cross-system async notify | Server outbound HTTP | Slack, CI triggers | **Boundaries** - Plugins **must not** inject theme routes or modify Next build config. - Themes **must not** write to the database directly — only via toolkit → API. - **Hooks** (in-process, inbound, can mutate fields) vs **Webhooks** (outbound HTTP) are separate concepts. Hook + webhook flow on article publish: ``` article.service ├─ applyFilters('article.beforePublish') ← plugin sync rewrite summary, etc. ├─ persist ├─ doAction('article.afterPublish') ← plugin sync side effects └─ webhookService.dispatch('article.published') ← external systems ``` ## Registry [`plugins/package.json`](./package.json): ```json { "reactpress": { "local": ["hello-world"] } } ``` | Source | Metadata | Registry location | | :-------- | :--------------------------- | :------------------------------------------------ | | **local** | `plugins/{id}/plugin.json` | `reactpress.local` array | | **npm** | `plugin.json` inside package | catalog anchor (phase 2, same rules as theme npm) | To add a local plugin: id matches directory name and `plugin.json` `id`; add to `local` array. ## Manifest: `plugin.json` Schema: [`plugin.manifest.schema.json`](./plugin.manifest.schema.json). Parser: `toolkit/src/plugin/extension/plugin.ts`. ```json { "$schema": "../plugin.manifest.schema.json", "id": "my-plugin", "name": "My Plugin", "version": "1.0.0", "description": "…", "requires": ">=3.5.0", "server": { "module": "./dist/index.js", "hooks": { "subscribe": ["article.beforePublish"] } }, "settings": { "schema": { "type": "object", "properties": { "enabled": { "type": "boolean", "default": true } } } }, "permissions": ["article:read"], "capabilities": { "headless": true } } ``` | Field | Required | Description | | :-------------------------- | :------: | :----------------------------------- | | `id` | ✓ | kebab-case, matches directory name | | `name` / `version` | ✓ | Display name and semver | | `requires` | | Minimum ReactPress version | | `requiresPlugins` | | Other plugin ids this depends on | | `server.module` | ✓\* | Server entry (compiled JS) | | `server.hooks.subscribe` | | Declared hook names | | `admin.slots.subscribe` | | Built-in Admin slot ids (enum) | | `admin.menu` | | Standalone plugin settings page menu | | `settings.schema` | | JSON Schema for config form | | `src/locales/{locale}.json` | | Admin strings | | `permissions` | | Admin capability declarations | | `capabilities` | | Security capabilities (see below) | \* At least `server.module`, or one of `admin.slots` / `admin.menu` (Admin code convention: `src/admin/index.ts`). | WordPress plugin header | ReactPress | | :---------------------- | :---------------- | | Plugin Name | `name` | | Version | `version` | | Text Domain | `id` | | Requires at least | `requires` | | Requires Plugins | `requiresPlugins` | ## Plugin package structure Every plugin package uses the same layout: **manifest and i18n at the root**, **all TypeScript under `src/`**, split by runtime: ``` plugins/my-plugin/ ├── plugin.json # manifest (id, hooks, settings schema, admin slots) ├── package.json ├── tsconfig.json # extends ../tsconfig.base.json (server compile only) ├── README.md ├── src/ │ ├── server/ # Node Hook logic → tsc → dist/ │ │ ├── index.ts # export function register(hooks, ctx) │ │ └── … │ ├── admin/ # optional React Admin UI (bundled by web via Vite) │ │ ├── index.ts # export function registerAdmin(registry, ctx) │ │ └── … │ └── locales/ # Admin UI strings (en.json, zh.json, …) └── dist/ # plugin.json → server.module (./dist/index.js) └── index.js ``` | Path | Runtime | Build | | :------------- | :---------------------------------- | :--------------------------------- | | `src/server/` | NestJS `require()` at API startup | `pnpm run build` → `tsc` → `dist/` | | `src/admin/` | Admin SPA (Vite `import.meta.glob`) | Compiled when building `web/` | | `src/locales/` | Admin i18n API | No build — served as JSON | | `plugin.json` | Install / enable / config | No build | Shared server compiler defaults live in [`tsconfig.base.json`](./tsconfig.base.json). Admin TSX is excluded from plugin `tsc` and compiled by the Admin Vite app instead. Admin i18n lives in `src/locales/{locale}.json`; API `GET /extension/plugins/:id/locales/:locale` reads from the installed plugin tree (themes keep `locales/` at package root). **Dependency rules** ``` plugins/{id}/src/server → @fecommunity/reactpress-toolkit/plugin/server only plugins/{id}/src/admin → toolkit/plugin/admin + toolkit/plugin/react server core → HookService only, no direct plugin imports web / themes → load plugin admin via convention src/admin/index.ts ``` ### Admin UI slots Symmetric with `server.hooks.subscribe`: **manifest declares slot enum**, **code convention defines entry path**. | Layer | Declaration | Notes | | :------------- | :------------------------------------------------------------ | :-------------------------------------- | | `plugin.json` | `admin.slots.subscribe: ["article.editor.meta.afterSummary"]` | Built-in slot id enum drives Admin load | | Plugin package | Fixed `src/admin/index.ts` → `registerAdmin()` | No path in manifest | | Core pages | `` | Host mount point | **Manifest example:** ```json "admin": { "slots": { "subscribe": ["article.editor.meta.afterSummary"] }, "menu": { "title": "SEO Enhancement", "path": "/plugins/seo/settings", "permission": "extension:manage" } } ``` **Built-in slots** (`AdminSlotIds` / JSON Schema `enum`): | Slot id | Location | | :------------------------------------ | :--------------------------------- | | `article.editor.meta.afterSummary` | Article editor · below summary | | `article.editor.sidebar.afterPublish` | Article editor · below publish box | **Core page mount:** ```tsx import { AdminSlot, AdminSlotIds } from '@fecommunity/reactpress-toolkit/plugin/react'; ; ``` **Plugin admin entry** (`plugins/{id}/src/admin/index.ts`): ```typescript import { AdminSlotIds, type PluginAdminModule, type PluginAdminRegistry, type PluginAdminContext, } from '@fecommunity/reactpress-toolkit/plugin/admin'; export function registerAdmin(registry: PluginAdminRegistry, ctx: PluginAdminContext): void { registry.registerSlot(AdminSlotIds.ARTICLE_EDITOR_META_AFTER_SUMMARY, MyPanel, { priority: 10, }); } export default { registerAdmin } satisfies PluginAdminModule; ``` Slot component props: `{ context, pluginId, config }`. `context` is defined by the host page (e.g. `ArticleEditorAdminSlotContext` with `draft` / `patch` / `translate`). When enabled and manifest includes `admin.slots` or `admin.menu`, `PluginAdminProvider` loads `plugins/{id}/src/admin/index` by convention; enable/disable triggers re-bootstrap. **Admin dependency rules** ``` plugins/{id}/src/admin → @fecommunity/reactpress-toolkit/plugin/admin + plugin/react + react/antd ``` Server entry contract: ```typescript import type { HookService, PluginContext } from '@fecommunity/reactpress-toolkit/plugin/server'; export function register(hooks: HookService, ctx: PluginContext): void { hooks.addFilter( 'article.beforePublish', async (article) => { return article; }, { priority: 10, pluginId: ctx.id } ); } // optional: cleanup timers on deactivate export function deactivate(): void {} ``` ## Lifecycle | Action | Effect | | :------------ | :---------------------------------------------------------------- | | **Install** | Materialize to `.reactpress/plugins/`; write `installedPlugins[]` | | **Enable** | Write `activePlugins[]`; Server **hot-loads** `server.module` | | **Disable** | Remove hooks; optionally call `deactivate()` | | **Uninstall** | Must disable first; delete runtime directory | Unlike themes: plugin enable/disable **does not require API restart**; theme enable requires Next restart (:3001). Persistence (`Setting.globalSetting.plugins`): ```typescript { installedPlugins: string[]; activePlugins: string[]; // ordered — affects load order entries: Record; activatedAt?: string; loadError?: string; }>; } ``` ## Hook system | Hook | Type | Timing | Payload | | :---------------------- | :----- | :------------- | :--------------------------- | | `article.beforeCreate` | filter | Before create | `Partial
` | | `article.beforePublish` | filter | Before publish | Mutate fields (e.g. summary) | | `article.afterPublish` | action | After publish | `{ article, isNew }` | | `comment.beforeCreate` | filter | Before comment | Can `__hookReject` | | `comment.afterCreate` | action | After comment | `{ comment, createByAdmin }` | Reject a filter (toolkit `createHookRejectValue` / `getHookReject`): ```typescript import { createHookRejectValue } from '@fecommunity/reactpress-toolkit/plugin/server'; return createHookRejectValue(comment, { message: 'Comment rejected', statusCode: 400 }); ``` Same hook name sorted by `priority` (default 10); failed plugins write `loadError` without affecting others. ## Build a new plugin **Standalone repo (recommended for third-party plugins):** use the [reactpress-plugin-starter](https://github.com/fecommunity/reactpress-plugin-starter) template — hooks, Admin slots, settings page, and build scripts out of the box. Develop outside the monorepo, then install via npm (phase 2) or copy into `plugins/{id}/`. **In this monorepo:** 1. Copy [`hello-world/`](./hello-world/) → `plugins/my-plugin/` 2. Edit `plugin.json`, `src/server/index.ts` 3. Add to [`plugins/package.json`](./package.json) → `local` 4. Build and enable: ```bash pnpm --filter @reactpress/plugin-my-plugin run build # or pnpm run build:plugins pnpm dev # compiles local plugins on demand before start ``` 5. Admin **Plugins** → Install → **Enable** ## Install & API | Action | HTTP | | :------------ | :--------------------------------------------------------------- | | List | `GET /api/extension/plugins` | | State | `GET /api/extension/plugins/state` | | Install | `POST /api/extension/plugins/:id/install` | | Enable | `POST /api/extension/plugins/:id/activate` | | Disable | `POST /api/extension/plugins/:id/deactivate` | | Uninstall | `DELETE /api/extension/plugins/:id` | | Config | `PUT /api/extension/plugins/:id/config` (auto reload after save) | | Admin locales | `GET /api/extension/plugins/:id/locales/:locale` | Plugins with `settings.schema` show a **Settings** link opening a WordPress-style config page (`/plugins/{id}/settings`). ```bash reactpress plugin list reactpress plugin install hello-world ``` ## Permissions & security - Install/enable/config requires **admin JWT** + `extension:manage` (web route layer). - **Enabled plugins = trusted code**: `require()` in Node process, same model as WordPress; only admins can install/enable. - **`server.module` path constraint**: relative paths inside plugin dir only (no `..`, no absolute paths); extensions limited to `.js`/`.cjs`/`.mjs`. - **Config writes**: server validates against manifest `settings.schema` (Ajv); rejects dangerous keys like `__proto__`. - **Materialize copy**: production skips symlinks when copying plugin files. - **Plugin id**: global kebab-case validation; invalid ids in `globalSetting.plugins` are ignored. | capability | Meaning | Runtime enforcement | | :----------- | :------------------------- | :-------------------- | | `headless` | Server only, no Admin UI | Documented convention | | `network` | Outbound HTTP | Phase 2 | | `filesystem` | Read/write outside project | Phase 2 | | `database` | Register entities | Phase 2 | Manifest `permissions[]` declares Admin UI capabilities; server API boundary is currently **admin role**. ## Typical use cases | Scenario | Mount | | :--------------------------- | :--------------------------------- | | Mutate fields before publish | `article.beforePublish` filter | | Comment filtering | `comment.beforeCreate` filter | | Post-publish analytics | `article.afterPublish` action | | Server-only, no UI | `server.module` + `headless: true` | **Not suitable for plugins**: visitor page components (use themes), large DB schema changes (core migration PR). ## Build commands ```bash pnpm run build:plugins pnpm --filter @reactpress/plugin-hello-world run build pnpm --filter @reactpress/plugin-hello-world run typecheck ``` Full `pnpm build` includes plugins after toolkit, before server. ## Built-in plugins | id | Name | Description | | :-------------------------------------- | :----------------- | :----------------------------------------------------- | | [`hello-world`](./hello-world/) | Auto Summary | Generate summary from body/title when empty on publish | | [`seo`](./seo/) | SEO Enhancement | Slug, keywords, meta description with auto-fill | | [`image-optimizer`](./image-optimizer/) | Image Optimization | Analyze legacy assets, batch WebP variants | ## Related code | Module | Role | | :------------------------------------------------------ | :------------------------------- | | `server/src/modules/extension/plugin.service.ts` | Install, enable, config | | `server/src/modules/extension/plugin-loader.service.ts` | Dynamic load + `register()` | | `server/src/modules/hook/hook.service.ts` | Hook registry | | `toolkit/src/plugin/server/` | Plugin SDK | | `toolkit/src/plugin/extension/plugin.ts` | Manifest parsing and state types | | `cli/out/lib/plugin-build.js` | Dev on-demand compile | | `web/src/modules/plugins/` | Admin plugin list | ## Roadmap (not implemented) - npm catalog and `reactpress plugin add` - Dynamic plugin Admin `admin.entry` loading (custom React settings UI) - `reactpress plugin create` CLI (scaffold: [reactpress-plugin-starter](https://github.com/fecommunity/reactpress-plugin-starter)), plugin marketplace ## References - [reactpress-plugin-starter](https://github.com/fecommunity/reactpress-plugin-starter) — standalone plugin template (hooks, Admin UI, settings) - [WordPress Plugin Handbook](https://developer.wordpress.org/plugins/) - Root [ARCHITECTURE.md](../ARCHITECTURE.md) — system architecture and extensibility - [`themes/README.md`](../themes/README.md) — theme registry and three-layer model