# ZnoteX plugins A plugin is a folder in `plugins/`. It can add public pages, admin pages, database tables and behaviour **without editing a single ZnoteX file** — which is the whole point: the next update replaces ZnoteX and leaves your work alone. Install, update, enable and disable them in **Admin Panel → Plugins**. **Installing one:** download it, unzip the folder into `plugins/`, reload the panel, press *Install*. That runs its `install.sql` and switches it on. ZnoteX never downloads a plugin by itself — a plugin is PHP that runs on every page of your site, so putting the files there stays a deliberate act. **Updating one:** replace the folder with the newer version. If its `plugin.json` carries a higher `version` than the one recorded at install time, an *Update* button appears and re-runs `install.sql`. That is why the file has to be idempotent — see below. --- ## The folder ``` plugins/my_plugin/ plugin.json name, version, author, description [required] plugin.php registers hooks and helpers [optional] pages/.php public page at page.php?plugin=my_plugin&p= admin/.php admin page, listed in the sidebar install.sql tables, created when the plugin is enabled assets/ css, js, images ``` Only `plugin.json` is required. A plugin that just reacts to a hook is a `plugin.json` and a `plugin.php`, nothing else. The folder name is the plugin's identity: lowercase letters, digits, `-` and `_`. A folder starting with `_` is ignored, which is how you park one. ### plugin.json ```json { "name": "My Plugin", "version": "1.0.0", "author": "You", "description": "One or two sentences shown in the admin panel.", "url": "https://example.com", "requires": { "znotex": ">=2.0.0 <3.0.0", "php": ">=8.1", "api": "^1.0", "extensions": ["json"] } } ``` The plugin version is independent from theme and ZnoteX versions. The `requires` object declares which host environment the plugin supports. ZnoteX refuses to install, enable or load an incompatible plugin. The legacy string form, such as `"requires": "2.0.0"`, remains supported and means ZnoteX 2.0.0 or newer. --- ## plugin.php Loaded on **every request** while the plugin is enabled, right after the database and the settings are up. So: - register hooks and declare functions here — that is all it is for; - never print anything; - keep it cheap. A query here is a query on every page of the site. Do the work inside the hook, where it only runs when it is needed. A `plugin.php` that throws is skipped and logged. One broken plugin does not take the site down. ### Stable extension API ```php $api = znote_plugin_api('my_plugin'); $api->on('shop.purchased', function (array $data): void { }); $value = $api->setting('enabled', '1'); $cache = $api->cache('catalogue', 300); $url = $api->url('shop'); $asset = $api->asset('style.css'); $db = $api->database(); ``` The API also exposes `config()`, `setSetting()`, `dispatch()`, `filter()`, `collect()`, `allows()`, `apiVersion()` and `znoteVersion()`. Plugin settings and cache keys are automatically isolated under the plugin name. --- ## Pages Drop `pages/shop.php` into your plugin and it is live at `page.php?plugin=my_plugin&p=shop`. Nothing to register. The file is a **fragment**: `page.php` has already run `engine/init.php` and opened the theme, so `$config`, `$user_data` and the `mysql_*` helpers are all there, and the active theme wraps whatever you print. Do not include `init.php`, and do not print a header or a footer. `?p=` is matched against the files that actually exist, so it can never reach anything outside `pages/`. A page of a plugin that is not installed and enabled returns 404. `plugins/.htaccess` blocks direct requests to anything but `assets/`, so nobody can run one of your files outside `page.php` or read your `install.sql`. Guard your pages anyway — someone will run ZnoteX on a server that ignores `.htaccess`: ```php if (!isset($config)) { http_response_code(403); die('Direct access denied.'); } ``` Link to one with `znote_plugin_url('my_plugin', 'shop')`, and to a file in `assets/` with `znote_plugin_asset('my_plugin', 'style.css')`. The `` gets a `page_my_plugin_shop` class, so a theme can style your page from CSS alone. --- ## settings.json Ship one and the plugin gets a configuration page for free - a **Settings** button next to it in Admin Panel > Plugins - instead of hand-coding a form: ```json { "fields": [ {"key": "api_key", "label": "API key", "type": "text", "default": ""}, {"key": "enabled", "label": "Enabled", "type": "bool", "default": "1"}, {"key": "mode", "label": "Mode", "type": "select", "default": "test", "options": {"test": "Test", "live": "Live"}}, {"key": "max_items", "label": "Max items", "type": "int", "default": "10", "min": 1, "max": 100}, {"key": "notes", "label": "Notes", "type": "textarea", "default": ""}, {"key": "webhook_secret", "label": "Webhook secret", "type": "password", "default": ""} ] } ``` Types: `text`, `textarea`, `password`, `bool`, `int` (with optional `min`/`max`), `select` and `checklist` (both need `options`). Every field is optional except `key` and `type`. Saved values live under the same namespace `$api->setting()` already reads, so `plugin.php` sees exactly what the generated form saved: ```php $mode = $api->setting('mode', 'test'); ``` --- ## Admin pages Drop `admin/orders.php` into your plugin and it appears in the admin sidebar. It is written exactly like a built-in module — see `admin/modules/_template.php` — with the same docblock header and the same `acp_*` helpers: ```php ...'; }); ``` An optional third argument is the priority, default `10`, lowest first. A callback that throws is caught and logged, and the site carries on. That is the difference between an extension point and a landmine. ### The hooks ZnoteX fires | Hook | Kind | When | `$data` | |---|---|---|---| | `plugins.loaded` | notify | every plugin is loaded | — | | `page.head` | collect | before `` | — | | `page.footer` | collect | before `` | — | | `shop.price` | filter | before a purchase is priced | `account_id`, `offer_id`, `offer` | | `shop.purchased` | notify | after the points are taken | `account_id`, `offer_id`, `type`, `itemid`, `count`, `points` | | `account.registered` | notify | after an account is created | `name`, `email` | | `character.created` | notify | after a character is created | `name`, `account_id`, `vocation` | | `character.renamed` | notify | after an admin renames a character | `player_id`, `old_name`, `new_name` | | `payment.completed` | notify | after a real-money payment is recorded | `provider`, `reference`, `provider_reference`, `account_id`, `price`, `currency` | `shop.price` is the one to reach for when you want to change what something costs. Its result is used for all three of the affordability check, the points actually deducted and the shop log, so they cannot disagree. `page.head` and `page.footer` are injected into the theme's own output, so they work with themes written long before your plugin existed — including ones that never call a plugin function. ### Your own hooks Publish one and other plugins can extend yours: ```php znote_hook('coupon.redeemed', array('code' => $code, 'account_id' => $id)); ``` ### Need a hook that isn't there? Adding one is two lines in the core file, and hooks with no listeners cost almost nothing. Open an issue rather than forking. --- ## The example `plugins/shop_coupons/` is a working plugin that uses every one of these: a public page, an admin page, its own tables, a filter hook that discounts a purchase, a notify hook that consumes the discount afterwards, and a collect hook that puts a banner in the footer. Read it top to bottom — it is commented as a tutorial rather than as production code. --- ## Checklist - [ ] Folder name is lowercase, unique, and matches nothing in ZnoteX. - [ ] `plugin.json` is valid JSON. - [ ] `plugin.php` prints nothing and runs no queries at load. - [ ] `install.sql` is idempotent. - [ ] Tables and functions are prefixed with the plugin name. - [ ] Every form has `` (admin) or `Token::create()` (public). - [ ] Every value that reaches SQL goes through `esc()` or `(int)`. - [ ] Every value that reaches the page goes through `h()` or `htmlspecialchars()`. - [ ] It still works when it is disabled — that is, nothing else references it.