# FlatBB plugin development: everything an AI assistant needs

Online: https://1bdd7061-c8c1-49c0-a8df-8f12c7a0dddb.s2.lat/dev/plugins · marketplace: https://1bdd7061-c8c1-49c0-a8df-8f12c7a0dddb.s2.lat/market

Read all of it before writing code. You write plugins/<id>/plugin.php; the member zips the folder and uploads it under Admin -> Plugins -> Upload plugin.


==============================================================================
Build a plugin with AI (docs/AI.md)
==============================================================================

A FlatBB plugin is one folder with a `plugin.php` that follows a short, strict specification. An AI coding assistant such as Claude Code, Codex, Cursor or ChatGPT can write one from a single prompt: you describe the plugin, the assistant reads the specification, you upload the zip. No command line is needed.

## 1. Give your assistant this prompt

```text
Read https://www.flatbb.com/dev/plugins.md first.
Then create a FlatBB plugin plugins/<id>/plugin.php that:
<what it does, where it shows up, its settings, who may use it>.
Follow every rule in the document.
Give me the finished folder as a zip I can upload under Admin -> Plugins.
```

`https://www.flatbb.com/dev/plugins.md` is one plain-text file with everything the assistant needs: this page, the plugin specification, every hook and region, the core API and the theme rules. It follows the latest FlatBB release.

What to write in the angle brackets, so the first answer is the right one:

- **Where it shows up**: a sidebar card, a line under every post, a new page at `/something`, an admin page, a button on the topic page.
- **Its settings**, with their defaults: "a limit, 20 by default".
- **Who may use it**: everyone, members, moderators, administrators.
- **What it stores**, if anything: "remember which members voted".

## 2. Install it on your forum

Open **Admin → Plugins → Upload plugin** and choose the zip. A new plugin is switched on right away; its settings open from its row in the list. If something does not work, paste the error or describe what you see back to your assistant and upload the fixed zip: an update keeps the plugin's data and settings.

## 3. Share it with everyone (optional)

Publish the zip at https://www.flatbb.com/market/publish, or connect your forum to the marketplace and publish from its admin. Other forums then install it in one click. The details, including updates and the checks a package has to pass, are in [Publishing a plugin](PUBLISH.md).

## A theme instead of a plugin

Ask for a theme in the same prompt: "…create a FlatBB theme plugins/<id>/ with `'type' => 'theme'` that looks like <colours, mood, density, corners, fonts>. Support light and dark mode." Upload it under **Admin → Appearance → Themes**.

## Working inside a copy of FlatBB

With the FlatBB source on your computer, the assistant can also check its own work:

```text
Read CLAUDE.md, docs/PLUGIN.md and docs/HOOKS.md in this repository,
then look at plugins/hello/plugin.php.
Create the plugin plugins/<id>/plugin.php that:
<what it does, where it shows up, its settings, who may use it>.
Follow every rule in docs/PLUGIN.md. When done run php -l and
php flatbb plugin:check <id> and fix any findings.
```

Two bundled plugins are meant to be copied: `plugins/hello` (the minimum: a footer badge and a page) and `plugins/nav_menu` (a table, an admin page with a form and a list region: the shape of a real plugin).

## What changed

New hooks, new regions and rule changes, release by release: [What's new for plugin authors](WHATS-NEW.md).

## Writing plugins by hand

The reference your assistant reads is written for people too: [Plugin specification](PLUGIN.md), [Hooks and regions](HOOKS.md), [Core API](API.md), [Themes and layout](THEME.md), [Database](DATABASE.md) and [Architecture](ARCHITECTURE.md).


==============================================================================
Plugin specification (docs/PLUGIN.md)
==============================================================================

This is the complete specification for creating, changing and reviewing FlatBB plugins.
Read it fully before writing code, then look at `plugins/hello/plugin.php` and the plugin closest to what you need.
Implement against the real functions in `core/` (see `docs/API.md`); do not invent APIs.

## 1. Work order

1. Decide the plugin **id** (lowercase letters, digits, underscores, 2–40 chars, e.g. `word_filter`), its scope, settings, data, pages, permissions, external requests and scheduled jobs.
2. Prefer core functions, hooks, regions and the settings schema over custom infrastructure. If the plugin mechanism cannot do it, add a hook to core instead of editing core behaviour from the plugin.
3. Put all logic in `plugins/<id>/plugin.php`. Extra files are allowed (`assets/`, `lang/`, `README.md`, helper `.php` files you `require` from plugin.php) but the manifest must be returned by `plugin.php`.
4. New plugin: verify defaults, enable, disable, uninstall. Existing plugin: stay compatible with old settings and data, bump the patch version at least, update the description if user-visible behaviour changed.
5. Finish with `php -l plugins/<id>/plugin.php` and `php flatbb plugin:check <id>`, then go through the checklist at the end.

## 2. Minimal plugin

```php
<?php
if (!defined('FLATBB')) exit;

function hello_footer(string $html, array $ctx): string
{
    return $html . '<span class="hello-badge">' . h((string)plugin_setting('hello', 'text', 'Hello')) . '</span>';
}

function hello_css(): string
{
    return '.hello-badge{color:var(--brand);font-size:var(--font-size-xs)}';
}

return [
    'id'          => 'hello',
    'name'        => 'Hello Badge',
    'version'     => '1.0.0',
    'description' => 'Shows a small greeting badge in the footer.',
    'author'      => 'your-name',
    'requires'    => ['flatbb' => '0.1.0'],
    'hooks'       => ['region.footer.right' => 'hello_footer'],
    'settings'    => ['text' => ['type' => 'text', 'label' => 'Badge text', 'default' => 'Hello', 'max' => 40]],
    'assets'      => ['css' => ['hello_css']],
];
```

Register it once with `php flatbb plugin:sync` or Admin → Plugins → "Scan plugins folder", then enable it.

## 3. Manifest reference

| Key | Required | Meaning |
| --- | --- | --- |
| `id` | yes | Same as the directory name. |
| `type` | no | `'theme'` makes the plugin a theme ([THEME.md](THEME.md)): one at a time, managed under Admin → Appearance → Themes, with `tokens`, `screenshot` and template overrides in `views/`. Leave it out for a plugin. |
| `name`, `version`, `description`, `author` | yes | `version` is semantic `x.y.z`. `description` is for end users: what it does, no tech words. |
| `url` | no | Homepage or repository. |
| `requires` | no | `['flatbb' => '0.1.0']` minimum core version. |
| `hooks` | no | `['hook.name' => 'callback' \| ['cb1', 'cb2']]`. See `docs/HOOKS.md`. |
| `routes` | no | `['/path' => 'callback', '/path/{id}' => 'callback']`. Patterns as in `core/router.php`. |
| `csrf_exempt` | no | Paths from `routes` that authenticate with an API token instead of a browser session (e.g. `['/api/myid/webhook']`). Every other POST is rejected by the dispatcher without a valid CSRF token. |

The manifest is **data**: strings, numbers, booleans, nested arrays and constants only, no calls, variables or expressions. It is read with PHP's tokenizer without executing the file (the admin lists disabled plugins that way, the marketplace validates uploads that way) and cached when the plugin is scanned; `plugin:check` refuses a manifest that cannot be read statically. `plugin:package` writes a `plugin.json` copy into the zip for tools that want the metadata without PHP; you never edit it.
| `importer` | no | Makes the plugin an importer listed under Admin → Import: `['from' => 'flarum', 'label' => 'Flarum', 'page' => 'run', 'step' => 'myid_step', 'cli' => 'myid_cli']`. See §13b. |
| `admin_pages` | no | `['key' => ['label' => 'Menu label', 'callback' => 'fn']]` → `/admin/ext/<id>/<key>`. Call `need_admin()` inside. |
| `settings` | no | Declarative settings; the admin form is generated (see §5). |
| `assets` | no | `['css' => [...], 'js' => [...]]`, each item a function name returning source, or a file path relative to the plugin dir. Bundled into one file for all plugins. |
| `cron` | no | `['job' => ['callback' => 'fn', 'interval' => 3600 \| 'fn_returning_seconds']]`. |
| `install` | no | Function run when enabled the first time or after a version change. Must be idempotent. |
| `uninstall` | no | Function that drops the plugin's own tables/files. Never delete user content you do not own. |
| `price` | no | Reserved for the marketplace (0 = free). |

## 4. Naming and isolation (checked by `plugin:check`)

- PHP functions `myid_*`, constants `MYID_*`, classes `MyId*`.
- Database tables `plugin_myid_*`; never alter `fb_*` core tables. Store extra per-topic/post/user data in your own table keyed by id, or in the `meta` JSON column via hooks — never add columns to core tables.
- When you write a key into `fb_topics.meta`, read the column fresh (`val('SELECT meta FROM fb_topics WHERE id=?', [$id])`), merge your key, write, then `request_cache('topic_' . $id, null, true)`. Several plugins save their key in the same `topic.after_save` request, and `topic_by_id()` is cached per request — merging into the cached copy silently drops what the plugin before you just wrote.
- CSS classes/ids/variables `myid-*` / `--myid-*`; `data-myid-*` attributes; JS functions and globals `myid_*`; browser storage keys `myid_*`.
- Custom hooks fired by your plugin: `myid.event_name`. Core hooks keep their original names.
- Plugins must not call each other's functions. Shared needs go into core.
- `plugin.php` starts with `if (!defined('FLATBB')) exit;`.
- Read/write files only under `plugin_path($id, ...)`, `DATA_DIR . '/myid_*'` or `UPLOAD_DIR`. Never hard-code paths.

## 5. Settings

Declare a schema; the admin page renders the form and validates input:

```php
'settings' => [
    'enabled'   => ['type' => 'checkbox', 'label' => 'Enable', 'default' => 1],
    'limit'     => ['type' => 'number', 'label' => 'Items', 'default' => 5, 'min' => 1, 'max' => 50],
    'mode'      => ['type' => 'select', 'label' => 'Mode', 'options' => ['a' => 'A', 'b' => 'B'], 'default' => 'a'],
    'text'      => ['type' => 'text', 'label' => 'Title', 'default' => '', 'max' => 80, 'help' => 'Shown above the list'],
    'html'      => ['type' => 'html', 'label' => 'Custom HTML', 'rows' => 6],
    'color'     => ['type' => 'color', 'label' => 'Accent', 'default' => '#e7672e'],
    'icon'      => ['type' => 'icon', 'label' => 'Icon', 'default' => 'star'],
],
```

An `icon` setting shows the site's icon field (the same one Admin → Categories and Menus use): the built-in icons, an emoji, or one of the icons the admin uploaded. Draw the stored value with `icon_any($value)`; never store an icon of your own in another format. In a form of your own, use `icon_picker($name, $value)` and read it back with `icon_from_post($name)`.

Read with `plugin_setting('myid', 'limit', 5)` or `plugin_settings('myid')`. Defaults come from the schema, so old installs missing a key still work.
Write programmatically with `plugin_save_settings('myid', $array)`.

Where it shows up: Admin → Plugins → *Settings* opens a drawer beside the list with this form; the same form is reachable as a full page via `?full=1`. Keep everything declarative when you can — that is what keeps every plugin's settings looking and behaving the same. Only for pages that need tables, charts or multi-step actions register `admin_pages` (`['key' => ['label' => 'Menu label', 'callback' => 'myid_admin_page']]`); those appear as links at the top of the settings drawer and under "Plugins" in the admin menu, and render with `admin_page($title, $html, 'ext.myid.key')`. Never put destructive actions inside a settings form; use `admin_row_menu()` + a confirmation.

## 6. Hooks and regions

Signature for every hook: `function myid_x($value, array $ctx)`. Return the new value, or `null` to leave it unchanged.
Events (fired with `fire()`) ignore the return value.

- **HTML regions** (`region.header.left`, `region.sidebar.right.top`, `region.footer.right`, …): `$value` is HTML, append to it.
- **List regions** (`region.header.nav`, `region.sidebar.left.nav`, `region.header.user_menu`, `region.post.actions`, `region.sidebar.right.cards`, …): `$value` is an array keyed by item id. Add `['label' => …, 'url' => …, 'icon' => …]` or `['html' => …]`. Insert your item with your plugin id as key (`myid` or `myid_<n>`). Optional keys the core honours for every list region: `weight` (int, lower first; equal weights keep insertion order; the marketplace links use 50 and 60), `visible` (`everyone` default, `members`, `admins`: filtered by the core, do not re-check in your callback), `new_tab` (bool, header links open in a new tab). Admins can hide any single item per region under Admin → Widgets, so never hard-code your item as mandatory. Reference example: `plugins/nav_menu/plugin.php` (one table, one admin page, one list-region hook).
- **Inline regions inside loops** (`region.topic_list.item.*`, `region.post.*`): called once per row/post. **No database access.** Use data already present in `$ctx['topic']`/`$ctx['post']`, or attach data beforehand with the batch hooks `topic_list.rows` / `topic.posts` (called once per page with all rows).
- **Admin control**: every region appears in Admin → Appearance → Widgets (the menus under Menus), where admins can switch your plugin off per region or add HTML blocks. Do not fight that with CSS.
- **Front end**: every region element carries `data-slot="<region>"`; select with `[data-slot~="post.actions"]`. In-loop slots repeat; scope by `[data-post-id]` / `[data-topic-id]`.

Frequently used hooks (full list in `docs/HOOKS.md`):

| Hook | Use |
| --- | --- |
| `app.boot` | Preload data once per request. |
| `topic_list.rows` / `topic.posts` | Batch-attach data to topic rows / posts (the right place to query). |
| `topic.before_save` / `post.before_save` | Validate or modify content (return array; call `fail('message')` to reject). |
| `topic.after_save` / `post.after_save` | React to new content (index, notify, award). |
| `markdown.after` | Post-process rendered HTML (embeds, emoji). Called per post: no DB. |
| `page.before_output` | Whole document: page-level placeholder replacement. |
| `region.sidebar.right.cards` | Add a card: `$cards['myid'] = card('Title', $html)`. Key it by your plugin id: Admin → Widgets lists the card under that id (drag to order, switch to hide) even on pages where your callback adds nothing. |
| `region.member.labels` / `region.member.stats` / `region.member.actions` | Show something about a member. One hook reaches every place the core draws a member: the sidebar member card (`place` `card`), the topic author card (`author`), the account menu (`menu`) and the profile (`profile`); check `$ctx['place']` to pick. Stats are `label`, `value`, `url`, `sub`, and `progress` (0..1) for a bar; actions are `label`, `url`, `icon`, `primary`, `count`. Prefer these to the place-specific `user.profile.*` and `header.user_menu.*` regions. `region.member.sections` adds a whole card beside the lists on a profile (`title`, `html`, `url`, `link`). |
| `region.composer.toolbar` | Add editor buttons. |
| `api.<action>` | JSON endpoints at `/api/<action>`. |
| `admin.settings_fields` | Add site settings groups. |
| `cron.jobs` | Register jobs dynamically. |

### Where an entry goes

Decide what the entry is, then use the one place for it. Every place is a list the member or the admin already knows; do not add a card or a menu of your own to show links.

| The entry is… | Put it in | Hook |
| --- | --- | --- |
| Something to do (post, check in) | Buttons of the member card, the account menu, the profile | `region.member.actions` (`post` => true for a POST button) |
| Something of mine (messages, orders, drafts, invites) | The account menu, group `you`; the member card shows these as shortcuts on its own (`card` => false keeps one out) | `region.header.user_menu` |
| A list of my content (topics, replies, favourites) | A tab of the profile | `region.user.profile.tabs` |
| A setting of mine (privacy, notifications) | A tab of Settings, never the menus | `region.user.settings.tabs` |
| A page inside a feature that already has a hub (a leaderboard in Growth) | A tab of that hub | the hub's own list, such as `region.growth.tabs` + `growth.tab_page` |
| A place on the site to go to (a shop, a tag square) | One link in the left menu, in a group: `community`, `tools`, or your own with `group_label` | `region.sidebar.left.nav` |
| Meta and utilities (RSS, downloads, the API) | The footer links | `region.footer.links` |
| A site-wide switch (colour scheme, language) | The header, right side | `region.header.right` |

Every item of these menus can be renamed, relinked, hidden or moved by the admin under Admin → Appearance → Menus, so give yours a stable key (your plugin id) and a plain label; never fight an admin's edit in your callback. A list region of your own becomes editable there when you add it to the filter `menus.known`.

One plugin, one link in the left menu: a feature with several pages opens one page and shows the others as its tabs. `plugin:check` warns when a plugin adds more. Past a number of links (Admin setting `nav_visible`, 8) the left menu folds the rest under More, and an admin orders or hides any item under Admin → Widgets.

### Permissions, uploads, hidden content and pages

Four hooks cover what bigger plugins need (trust levels, object storage, reply-to-see, a portal). Use them instead of patching anything.

**Change a permission for one member: `user.can`.** The group decides first; your filter gets its answer and returns the final one. Guests and admins never reach it.

```php
function myid_can(bool $ok, array $ctx): bool
{
    // members below level 2 may not post links or upload files yet
    if (in_array($ctx['permission'], ['upload'], true) && user_level((array)$ctx['user']) < 2) return false;
    return $ok;
}
// 'hooks' => ['user.can' => 'myid_can']
```

**Follow, refuse or move uploads: `upload.before_save`, `upload.after_save`, `upload.url`.** Return a message from `upload.before_save` to refuse a file (or change the file at `$ctx['tmp']` in place: compress, watermark). `upload.after_save` fires for attachments, avatars and site images (`$ctx['kind']`), with the saved path and file: copy it to object storage there. `upload.url` rewrites the address a file is served from; it runs for every avatar on a page, so it must not query.

```php
function myid_url(string $url, array $ctx): string
{
    return plugin_setting('myid', 'cdn', '') !== '' ? rtrim((string)plugin_setting('myid', 'cdn'), '/') . '/' . ltrim($ctx['path'], '/') : $url;
}
```

**Content not every reader may see: `topic.posts` + `markdown.excerpt`.** Hide it when the page is drawn (`topic.posts` runs once per page with every post and the reader known), and strip it from the source in `markdown.excerpt`, which every excerpt goes through: the search index, page descriptions, notifications and feeds. Doing only the first leaks the content everywhere else.

```php
function myid_excerpt(string $md, array $ctx): string
{
    return preg_replace('~\[hide\].*?\[/hide\]~s', '', $md) ?? $md;
}
```

**Serve a core page: `router.routes`.** Map a core path to your handler (a portal at `/`). Admin, sign-in, settings, setup and API addresses cannot be taken over, and Admin → Plugins tells the admin which pages a plugin serves.

```php
function myid_routes(array $routes, array $ctx): array
{
    $routes['/'] = 'myid_portal'; // the latest topics stay at /latest
    return $routes;
}
```

### Batch pattern (the only acceptable way to add per-row data)

```php
function myid_rows(array $rows, array $ctx): array
{
    $ids = array_column($rows, 'id');
    $extra = $ids ? rows_by_ids('plugin_myid_stats', $ids, 'topic_id,score', 'topic_id') : [];
    foreach ($rows as &$r) $r['myid_score'] = (int)($extra[(int)$r['id']]['score'] ?? 0);
    return $rows;
}
function myid_title_suffix(string $html, array $ctx): string   // loop hook: memory only
{
    $s = (int)($ctx['topic']['myid_score'] ?? 0);
    return $s > 0 ? $html . '<span class="myid-score">' . $s . '</span>' : $html;
}
// manifest
'hooks' => ['topic_list.rows' => 'myid_rows', 'region.topic_list.item.title_suffix' => 'myid_title_suffix'],
```

If a loop hook cannot receive its data through a batch hook, output a unique placeholder `<!--myid-<token>-<id>-->` and replace all of them at once in `page.before_output` with one `IN (...)` query. Never leave placeholders in the final HTML.

## 7. Routes and pages

```php
function myid_page(string $id = ''): never
{
    $me = need_login();                  // or need_admin(), or nothing for public pages
    $row = one('SELECT * FROM plugin_myid_items WHERE id=?', [(int)$id]);
    if ($row === null) not_found();
    page('Title', '<div class="card"><div class="card-body">' . h($row['title']) . '</div></div>', ['class' => 'page-myid']);
}
function myid_save(): never
{
    need_login();
    require_post();                      // POST + CSRF
    $title = post_str('title', 120);
    if ($title === '') fail(t('Title is required.'));
    db_insert('plugin_myid_items', ['user_id' => uid(), 'title' => $title, 'created_at' => now()]);
    flash(t('Saved.'));
    redirect(url('/myid'));
}
'routes' => ['/myid' => 'myid_page', '/myid/{id}' => 'myid_page', '/myid/save' => 'myid_save'],
```

- Links: `url('/myid/' . $id)`. Forms: include `csrf_field()`; add `data-ajax="1"` to submit via fetch (handler must respond with `redirect()`/`json_ok()`).
- AJAX-only endpoints: `json_ok([...])` / `json_error('msg', 400)`. `is_ajax()` tells you how the request came in.
- Page options: `['left' => false]` hides the left column, `['right' => $html]` replaces the right column, `['breadcrumbs' => [[label, url]]]`.
- Build UI with the helpers in `core/render.php`: `card()`, `tabs()`, `pagination()`, `form_row()`, `input()`, `select()`, `checkbox()`, `action_form()`, `editor()`, `avatar()`, `user_link()`, `icon()`.

## 8. Database

```php
function myid_install(array $manifest): void
{
    db_create_table('plugin_myid_items', [
        'id' => 'id', 'user_id' => 'uint', 'title' => 'string', 'body' => 'text', 'score' => 'int', 'created_at' => 'uint',
    ]);
    db_create_index('plugin_myid_items', 'ix_myid_items_user', ['user_id', 'created_at']);
}
function myid_uninstall(array $manifest): void
{
    db_drop_table('plugin_myid_items');
}
```

- Types: `id`, `uint`, `int`, `bigint`, `bool`, `float`, `string` (255), `key` (191, indexable/unique), `text`, `mediumtext`. Raw SQL types are allowed only if valid on both SQLite and MySQL 5.7.
- Writes: `db_insert()` (returns id), `db_update()`, `db_delete()`, `db_upsert($table, $data, $keys)` (keys need a PK or unique index), `db_insert_ignore()`, `db_increment()`.
- Multi-step writes in `tx(function () { ... })`.
- Portable helpers: `db_greatest()`, `db_random()`, `db_like()` (with `ESCAPE '\\'`), `sql_marks()`.
- Schema changes only in `install` (which runs again when the version changes — make it idempotent with `db_ensure_columns()`).
- Cache across requests with `save_settings(['myid_cache' => json_encode_value($v)])` + `setting('myid_cache')`; within a request with `request_cache('myid_x', fn() => ...)`.

## 9. Assets and front end

- Declare CSS/JS in the manifest; do not print `<style>`/`<script>` tags from hooks except in `region.head`/`region.body.end` when the content must be dynamic.
- Asset functions take no arguments and return source without tags. They run at bundle time (enable/disable), not per request, so they cannot depend on the current user or page. Pass dynamic values through `data-myid-*` attributes.
- Wrap JS in an IIFE. `window.FB` provides `base`, `csrf`, `uid`, `request(url, opts)` (fetch with CSRF header), `toast(msg, type)`, `initEditor(el)`. Listen to `fb:ready`, `fb:ajax`, `fb:editor` events.
- Use CSS variables only: `--bg --panel --panel-2 --line --line-soft --text --text-muted --text-subtle --brand --brand-hover --brand-soft --success --danger --warning --info` (+ `*-soft`), `--radius --radius-sm --shadow`, font sizes `--font-size-xs|sm|md|lg|xl|2xl`. No hard-coded colours or pixel font sizes; no `!important`; scope selectors under your own class.
- Reuse core classes for consistency: `.card`, `.card-head`, `.card-body`, `.btn`, `.btn-primary`, `.btn-sm`, `.tag-badge`, `.flag`, `.muted`, `.form-row`, `.table-wrap table.admin`.
- Static files (images) in `plugins/<id>/assets/` are served directly: `plugin_url($id, 'assets/logo.png')`.
- Right-to-left languages: write direction-neutral CSS. Use `margin-inline-start`/`-end`, `padding-inline-*`, `border-inline-*`, `inset-inline-*` and `text-align: start` instead of left/right; the core switches `<html dir="rtl">` when the language pack says so, and `[dir="rtl"]` rules cover the rest. Give user text (`.post-content`, titles, textareas) `dir="auto"`.

## 10. Security

- CSRF is verified by the dispatcher for every POST; you never need to remember it. A route that is called by machines with a token goes into `csrf_exempt` and must verify that token itself.
- Content Security Policy: inline `<script>` blocks need the request nonce, so write them with `script_tag('...js...')` (or `script_tag('', $src)` for an external file) instead of a raw tag, and never use inline event handlers (`onclick="..."`); put JS in the plugin's `assets` bundle. A plugin that loads scripts, styles or fonts from a CDN adds the host through the `security.csp` filter (`$p['script-src'][] = 'https://cdn.example.com'`).
- High-risk admin pages call `need_sudo()` after `need_admin()`: the admin re-enters the password once per confirmation window (Settings → Security, default ten minutes, 0 = off), so a stolen or script-ridden session cannot change what matters. Log what an admin changed with `admin_log('myid.action', $target, $detail)`; it shows under Tools and fires `admin.action`.
- The visitor's address is `client_ip()`; it already accounts for trusted proxies (Settings → Security), so never read the forwarded headers yourself.
- Never touch `$_POST`, `$_GET`, `$_REQUEST` or `$_COOKIE`: use `post_str()`, `post_int()`, `post_list()` (checkbox groups), `post_secret()` (passwords, untrimmed), `get_str()`, `get_int()`. `plugin:check` rejects direct superglobal access.
- `h()` everything that reaches HTML. In templates, `<?= ... ?>` must start with `h()`, `t()`, a core HTML helper (`icon()`, `avatar()`, `form_row()`, …) or `raw()` for HTML you already built safely; a bare variable fails the check. Markdown goes through `md()`.
- Permissions: `uid()`, `me()`, `need_login()`, `need_mod()`, `need_admin()`, `can('post'|'reply'|'upload'|'edit_own'|'delete_own')`, `is_admin()`, `is_mod()`, `can_edit_post()`, `can_manage_topic()`, `category_can_view()`.
- Files: whitelist extensions, never trust client mime, reuse `upload_store()`; never write under `plugins/` at runtime.
- External requests: curl with connect/total timeouts, size limits, no internal IPs, no credentials in URLs; never block page rendering on a remote call — do it in cron.
- Never log or display tokens, passwords, or full SQL.

## 11. Scheduled jobs

```php
function myid_cleanup(array $job): string
{
    $n = db_delete('plugin_myid_items', 'created_at<?', [now() - 86400 * 30]);
    return $n . ' removed';         // shown in Admin → Scheduled jobs
}
'cron' => ['cleanup' => ['callback' => 'myid_cleanup', 'interval' => 86400]],
```

Jobs run from `/cron?key=…` or `php flatbb cron`, under a lock, only when enabled. Make them idempotent and quick; use a queue table for long work.

## 12. Points (economy)

The core owns the balance (`fb_users.points`) and the private history (`fb_points_log`); plugins decide when points move.

```php
function checkin_reasons(array $r, array $ctx): array { return $r + ['checkin' => t('Daily check-in')]; }
function checkin_claim(): never
{
    $me = need_login(); require_post();
    if (!points_add((int)$me['id'], 5, 'checkin')) fail(t('Nothing to add.'));
    flash(t('+5 points!')); redirect(url('/'));
}
'hooks' => ['points.reasons' => 'checkin_reasons'], 'routes' => ['/checkin' => 'checkin_claim'],
```

- `points_add($user_id, $delta, $reason, $ref_id = 0, $note = '')` — negative delta spends; `points_of()`, `points_log()`, `points_top()` read.
- Always register your reason codes through `points.reasons` so the user's history shows a readable label; never write `fb_points_log` or `fb_users.points` directly.
- `points.before_change` lets a plugin veto or cap changes (return `false`); `points.after_change` is the place for badges, notifications or a shop.
- The history is private (Settings → Points, and admins). Show public totals through a `member.stats` item (the profile's own Points tile is already rendered when the user allows it).

## 13. Editor extensions

The composer is one component (`app/views/editor.php` + the editor block in `assets/app.js`). Stable contract for plugins:

- **Modes**: the tabs above the toolbar are the list region `composer.modes` (`write`, `preview`); add a mode with `label`, `icon`, `cmd` (your command toggles it) and `class` (on `.editor` while it is on) — see the Visual Editor plugin.
- **Buttons**: add to the `composer.toolbar` list region: `$buttons['myid_stamp'] = ['icon' => 'clock', 'title' => 'Timestamp', 'cmd' => 'myid_stamp', 'arg' => 'Y-m-d'];` (`arg` is passed to your handler; `['html' => …]` inserts raw markup).
- **Commands (JS)**: `FB.editor.register('myid_stamp', function (api, arg, el) { api.insert(new Date().toISOString().slice(0, 10)); });` The `api` object: `value(v?)`, `selection()` → `[start, end, text]`, `replace(start, end, text, cursor?)`, `insert(text)`, `wrap(before, after?, placeholder?)`, `prefix(lineStart)`, `block(text)`, `upload(files)`, `preview(bool?)`, `fullscreen(bool?)`, `status(text)`, `run(cmd, arg)`, `clearDraft()`. Registering an existing command name (e.g. `link`) overrides the built-in one.
- **Events**: `document` receives a cancelable `fb:editor` event before every command (`detail: {editor, api, cmd, arg}`; `preventDefault()` blocks it). `FB.editor.get(el)` returns the api of any `[data-editor]` element; `FB.editor.init(el)` initialises one you inserted dynamically.
- **Options** (PHP): hook `editor.options` filters `['preview','emoji','fullscreen','draft_days','upload','accept','scope']` per composer (ctx carries `name` and the topic/post being edited). `editor.emoji` filters the emoji list, `editor.help` the formatting-help rows.
- **Markup**: the stable selectors are `[data-editor]`, `textarea[name=body]`, `.editor-toolbar`, `[data-preview]`. Everything else may change.

## 13a. AI

The site has one AI connection, set up under Admin → Settings → AI (provider, address, model, key). A plugin that needs a model calls it instead of shipping its own client or asking for its own key:

```php
if (!ai_ready()) return;                       // not set up: stay quiet, or tell the admin in your settings page
$r = ai_chat('You file forum posts. Answer with JSON: {"tags": [...]}', $title . "\n\n" . $body,
    ['purpose' => 'myid', 'max_tokens' => 200, 'json' => true]);
if (!$r['ok']) return;                          // $r['error'] says why
$data = ai_json($r['text']);                    // the first JSON object in the answer, or null
```

- Two protocols cover nearly every service: `openai` (Chat Completions: OpenAI, DeepSeek, Qwen, Moonshot, OpenRouter, a local Ollama…) and `anthropic` (Claude). Your plugin never sees the key or the difference.
- The admin may add up to two backup connections: when the main one fails, `ai_chat()` asks the next by itself, so a plugin handles one failure path only (every connection failed). `$r['connection']` says which one answered.
- `ai_chat()` is one HTTPS request that can take seconds: call it on a user action or in a cron job, never in a loop, a list, a region or a transaction. Rate-limit it per member and cache answers for the same text.
- Treat the answer as untrusted input: check every value against what you allow (a category the writer may post in, a tag that passes `tags_parse()`), and `h()` it like anything else.
- `purpose` is your plugin id. Filter `ai.request` sees it and may change or refuse a request (quotas, redaction); event `ai.response` reports model and token usage.
- **Picking a category**: filter `topic.category_missing` (ctx `title`, `body`, `user`) runs when a new topic arrives without one; return a category id. Filter `topic.category_auto` returns true while your plugin can answer, which makes the category optional in the composer. The AI Classify plugin is the example.

## 13b. Importers

An importer brings another forum into a **new, empty** FlatBB forum. The core owns the job, Admin → Import and `php flatbb import <from>`; your plugin reads the source and hands rows to the core's writers, the only code that fills `fb_*` tables with imported content. Name the plugin after its source: `phpbb_importer`, "phpBB Importer". The Flarum Importer is the example.

```php
'importer' => ['from' => 'phpbb', 'label' => 'phpBB', 'page' => 'run', 'step' => 'phpbb_importer_step', 'cli' => 'phpbb_importer_cli'],
'admin_pages' => ['run' => ['label' => '', 'callback' => 'phpbb_importer_page']],   // your connect / check page, linked from Admin → Import
```

- **Start**: your page (or `cli($opts, $out)`) checks the source, then calls `import_start('myid', $phases, $data, $secret)`. `$phases` is the order of work with counts (`[['key' => 'users', 'label' => 'Members', 'total' => 3412], …]`); `$data` is your own state (connection without password, what the source has); `$secret` (a database password) is removed when the job ends. `import_ready()` says whether the forum may receive an import; `import_start()` removes the starter content first.
- **Step**: `step(array $job): array` runs one batch of `$job['phases'][$job['phase']]` (a few hundred rows), adds to its `done`, keeps its place in `$job['cursor']`, and returns `import_phase_next($job)` when the phase has no rows left. The core runs steps for a few seconds per request from the page (closing it pauses the import) or to the end from the command line; each batch is one transaction with the saved job, so a failed batch is retried from where it stopped. The core adds the Counters and Search index phases at the end.
- **Writers**: `import_user()` (keeps a bcrypt/argon hash, so members sign in with their old password; a member with the administrator's email becomes that account), `import_group()`, `import_category()`, `import_tag()`, `import_topic()` and `import_post()` (both may keep the source id; `REVIEW_PENDING` puts a post in the review queue), `import_like()`, `import_read()`, `import_copy_file()`. Posts are Markdown: convert the source format in your plugin. `import_note($text)` adds a line to the report.
- Keep your own maps (old id → new id) in your plugin's tables; clear them on the event `import.reset`, which fires when an import starts. `router.not_found` is the place to redirect the source forum's old addresses. Event `import.done` fires at the end.
- `import_steps_html($labels, $current)`, `import_stats_html($stats)` and `import_job_html($job, $back)` draw the steps, the numbers found and the progress, the same way for every importer.

## 14. Translations

Wrap user-facing strings in `t('English text')`. Ship `plugins/<id>/lang/<code>.php` returning `['English text' => 'Translation']`; it is loaded automatically for the active language. A pack for a right-to-left script adds `'__dir' => 'rtl'`. The language is chosen per visitor (preference, cookie, then the site default), so never cache translated HTML across requests. Print timestamps with `time_tag($ts)`: the browser re-renders them in the visitor's own time zone.

## 14a. Points: rules, awards and the ledger

The core keeps the balance, the history and the **rule table** (Admin → Points: what each action pays, a daily cap per member, on/off). Plugins declare rules and pay through them, so the admin tunes every number in one place:

```php
'hooks' => ['points.rules' => 'myid_rules', 'region.points.actions' => 'myid_points_action'],
function myid_rules(array $rules, array $ctx): array { $rules['myid_checkin'] = ['label' => t('Daily check-in'), 'amount' => 5, 'cap' => 1, 'once' => true, 'group' => t('Check-in')]; return $rules; }
points_award($user_id, 'myid_checkin', $day_key);   // pays what the rule says; false when off, capped for today, or already paid for that ref
points_add($user_id, -20, 'myid_shop', $item_id);   // a fixed amount, e.g. spending (negative delta)
points_revoke($user_id, 'myid_checkin', $ref, 'myid_undo'); // takes back what a rule paid for that ref
```

`cap` is per member per day (0 = none); `once` refuses a second payment for the same `ref_id`. Ledger lines link to the post or topic behind them for the core reasons; answer `points.ref_url` (ctx reason, ref_id) for yours. Members see the rules that are on under "How to earn points" on `/points`, and the list region `points.actions` puts a button there (check-in, tasks, leaderboard).

## 14b. Plugins that cost points

Your plugin is your own work under any licence you like: FlatBB's AGPL does not extend to plugins and themes (an additional permission, see `LICENSING.md`).

Every plugin is free unless its author sets a number of points on the marketplace (My plugins → Manage; the package and the manifest carry no price). A member pays those points once with their www.flatbb.com account, the author receives them, and the plugin is theirs for good: any forum where that account is connected (Admin → Plugins → Marketplace → Account) installs and updates it, or the zip is downloaded from the plugin page. There is nothing to check inside the plugin: no key, no licence, no expiry. Write it exactly like a free plugin.

## 15. Delivery checklist

- [ ] `php -l` and `php flatbb plugin:check <id>` pass.
- [ ] Everything is prefixed with the plugin id (functions, tables, CSS, JS, storage, hooks).
- [ ] No query in a loop; loop hooks are memory-only; no leftover placeholders.
- [ ] All state changes happen on POST (`require_post()` in handlers; admin pages may branch on `is_post()`, the dispatcher has verified the CSRF token already); permissions checked; output escaped; `php flatbb security:check` and `plugin:check` pass.
- [ ] Works on SQLite and MySQL 5.7 (no dialect SQL, schema via `db_*` helpers).
- [ ] Old settings/data still work; `install` and `uninstall` are idempotent.
- [ ] `version` bumped; `description` accurate; `README.md` describes settings and usage.
- [ ] Narrow screens, long text, empty states and error states handled.

## 16. Prompt to give an AI

No command line is needed to build or ship a plugin: the AI writes `plugins/<id>/plugin.php`, you zip that folder and upload it under Admin → Plugins → Upload plugin (or publish it to everyone at https://www.flatbb.com/market/publish). The complete specification, hooks and API are hosted as one plain-text file at https://www.flatbb.com/dev/plugins.md, so the prompt can be as short as this ([Build a plugin with AI](AI.md) walks through the whole process):

```
Read https://www.flatbb.com/dev/plugins.md first. Then create a flatbb plugin plugins/<id>/plugin.php that: <what it does, where it shows up, its settings, who may use it>.
Follow every rule in the document. Give me the finished folder as a zip I can upload under Admin -> Plugins.
```

Two bundled plugins are meant to be copied: `plugins/hello` (footer badge and a page, the minimum) and `plugins/nav_menu` (a table, an admin page with a drawer form and a list-region hook, the typical shape of a real plugin).

Inside a checkout of FlatBB the local files work the same way:

```
Read CLAUDE.md, docs/PLUGIN.md and docs/HOOKS.md in this repository, then look at plugins/hello/plugin.php.
Create the plugin plugins/<id>/plugin.php that: <what it does, where it shows up, its settings, who may use it>.
Follow every rule in docs/PLUGIN.md. When done run `php -l` and `php flatbb plugin:check <id>` and fix any findings.
```


==============================================================================
Hooks and regions (docs/HOOKS.md)
==============================================================================

Generated by `php flatbb hooks:list` on 2026-09-30. Do not edit by hand.

Filters receive `($value, array $ctx)` and return the new value (or null to keep it). Events receive `(null, array $ctx)`.
Regions are filters named `region.<position>` whose value is HTML (or an array for list regions). Inline regions run inside loops: **no database queries** in their callbacks.

| Hook | Kind | Description | Where |
| --- | --- | --- | --- |
| `account.after_login` | event | After a successful login. | `app/account.php` |
| `account.after_register` | event | After a new account was created. | `app/account.php` |
| `account.login_challenge` | filter | After the password was checked: return a URL to send the user to a second step (two-factor) instead of signing them in; the plugin finishes with login_pending_user() + login_user(). | `app/account.php` |
| `account.login_validate` | filter | Add errors to a sign-in attempt before the password is checked (ctx: username); a non-empty list refuses it. | `app/account.php` |
| `account.register_validate` | filter | Add validation errors to registration. | `app/account.php` |
| `admin.action` | event | After an admin action was logged (ctx: user_id, ip, action, target, detail). The security plugin subscribes here. | `core/security.php` |
| `admin.category_after_save` | event | After a category was created or edited (ctx: id, data); plugins keep their own per-category options here. | `app/admin_content.php` |
| `admin.category_save` | filter | Filter category data before saving. | `app/admin_content.php` |
| `admin.plugin_ops` | filter | Extra buttons on a plugin row. | `app/admin_system.php` |
| `admin.plugin_settings.before` | filter | HTML at the top of a plugin settings drawer (ctx: id, manifest). | `app/admin_system.php` |
| `admin.settings_fields` | filter | Add a settings section: $value['myid'] = ['Label', ['key' => [type, label, help, options, min, max]]]. Each section is a tab in Admin → Settings. | `app/admin.php` |
| `admin.settings_save` | filter | Filter settings before they are saved. | `app/admin.php`, `app/admin_ai.php` |
| `admin.tool` | event | Handle a custom tool action. | `app/admin_system.php` |
| `admin.tools` | filter | Add rows to Admin → Tools. | `app/admin_system.php` |
| `admin.user_saved` | event | After an admin edited a user. | `app/admin.php` |
| `ai.request` | filter | Filter: a request to the shared AI connection before it is sent (value: system, user, opts; ctx: purpose = the calling plugin). Change it, or return an array with an error key to refuse it (quotas, redaction). | `core/ai.php` |
| `ai.response` | event | Event: after each attempt on an AI connection (ctx: purpose, connection = 1 for the main one, 2 and 3 for the backups, model, usage [in, out] tokens, ok). A request that falls back fires it once per connection tried. For plugins that count or log usage. | `core/ai.php` |
| `api.<action>` | filter | Handle /api/<action>; return an array to respond as JSON. | `app/api.php` |
| `app.boot` | event | Every request after plugins are loaded. Preload data here. | `core/boot.php` |
| `auth.login.after` | filter | HTML below the sign-in form. | `app/views/login.php` |
| `auth.register.after` | filter | Html under the registration form (social sign-up buttons). | `app/views/register.php` |
| `composer.values` | filter | Filter the values a composer opens with: title, body, category_id, tags for a new topic (ctx mode new), body for a reply (ctx mode reply, topic). Used by drafts plugins. | `app/topic.php`, `app/views/topic.php` |
| `cron.jobs` | filter | Filter the list of scheduled jobs. | `core/cron.php` |
| `editor.emoji` | filter |  | `app/views/editor.php` |
| `editor.help` | filter |  | `app/views/editor.php` |
| `editor.options` | filter |  | `app/views/editor.php` |
| `feed.topics` | filter |  | `app/api.php` |
| `icon.paths` | filter | Add SVG icons: name => path markup. | `core/icons.php` |
| `import.done` | event |  | `core/import.php` |
| `import.reset` | event |  | `core/import.php` |
| `link.previewable` | filter | Filter: whether a link may get a preview card (bool; ctx url, host). Return false to keep a link a link, e.g. when your plugin shows its own player for that site. | `core/links.php` |
| `mail.send` | filter |  | `core/helpers.php` |
| `markdown.after` | filter | Rendered HTML of a post. | `core/markdown.php` |
| `markdown.before` | filter | Markdown source before rendering. | `core/markdown.php` |
| `markdown.excerpt` | filter | Filter: the Markdown a plain excerpt is made from (ctx: max): search index, page descriptions, notifications, feeds. Strip content not every reader may see. | `core/markdown.php` |
| `menus.known` | filter | Filter: the list regions an admin edits under Admin → Appearance → Menus (region => name). Add your plugin's own list region to make its items editable. | `core/hook.php` |
| `migrate.after_import` | event |  | `core/migrate.php` |
| `notification.after_create` | event | After a notification was stored. | `app/notification.php` |
| `notification.after_create_many` | event | After notify_many() stored the same notification for many members (ctx: user_ids, from_user_id, kind, topic_id, post_id). | `app/notification.php` |
| `notification.before_create` | filter | Filter/veto a notification (return null to skip). Also runs once per recipient inside notify_many() (ctx: many = true): no database queries. | `app/notification.php` |
| `notification.kinds` | filter | Icon and verb per notification kind (kind => [icon, verb]); plugins register their own kinds. | `app/views/notifications.php` |
| `notifications.rows` | filter | Filter rows on the notifications page. | `app/notification.php` |
| `page.before_output` | filter | The whole HTML document before it is sent. Use for page-level placeholder replacement. | `core/render.php` |
| `page.options` | filter | Filter the page() options (left/right columns, class, description, canonical, robots, breadcrumbs, title_full = the complete <title> text). | `core/render.php` |
| `permissions.known` | filter | Add permission keys shown in Admin → Groups. | `app/admin.php` |
| `plugin.after_install_zip` | event |  | `core/plugin.php` |
| `plugin.settings_saved` | event | After plugin settings were saved (ctx: id). | `app/admin_system.php`, `app/admin_theme.php` |
| `points.after_change` | event | After points were added or removed (ctx: user_id, delta, reason, ref_id, balance). | `core/points.php` |
| `points.before_change` | filter | Filter or veto a points change (return false to block). | `core/points.php` |
| `points.icon` | filter | The icon name of a points history line (ctx: reason, delta). Runs inside lists: no database access. | `core/points.php` |
| `points.reasons` | filter | Register point reason codes: $value['checkin'] = 'Daily check-in'. Labels show in the private history. | `core/points.php` |
| `points.ref_url` | filter |  | `core/points.php` |
| `points.rules` | filter |  | `core/points.php` |
| `post.after_delete` | event | After a reply was deleted or restored. | `app/topic.php` |
| `post.after_like` | event | After a like toggle. | `app/topic.php` |
| `post.after_save` | event | After a post was created or edited. A new reply that waits in the review queue fires it on approval. | `app/topic.php` |
| `post.before_save` | filter | New reply data (body, reply_to_id) before insert. | `app/topic.php` |
| `post.before_update` | filter | Reply body before an edit is saved. | `app/topic.php` |
| `region.<action>` | filter |  | `core/hook.php`, `core/render.php` |
| `region.admin.category.fields` | html region | Extra fields at the end of the category editor (ctx: category) | `app/admin_content.php` |
| `region.admin.dashboard.cards` | list region | Admin dashboard cards (list) | `app/admin.php` |
| `region.admin.menu` | list region | Admin menu items (list) | `app/admin_ui.php` |
| `region.admin.plugins.tabs` | list region | The tab row of Admin → Plugins: Installed plus what plugins add (list; ctx: active) | `app/admin_system.php` |
| `region.admin.themes.tabs` | list region |  | `app/admin_theme.php` |
| `region.auth.login.extra` | html region | Inside the sign-in form | `app/views/login.php` |
| `region.auth.register.extra` | html region | Inside the registration form | `app/views/register.php` |
| `region.body.end` | html region | Before </body> (scripts) | `app/views/layout.php` |
| `region.composer.extra` | html region | Extra fields inside the post/reply form | `app/views/topic.php`, `app/views/topic_form.php` |
| `region.composer.modes` | list region | Editor mode tabs above the toolbar (list: label, icon, cmd, class): Write and Preview; a visual editor adds its own | `app/views/editor.php` |
| `region.composer.toolbar` | list region | Editor toolbar buttons (list) | `app/views/editor.php` |
| `region.footer.left` | html region | Footer, left | `app/views/layout.php` |
| `region.footer.links` | list region | Footer links (list) | `app/views/layout.php` |
| `region.footer.right` | html region | Footer, right | `app/views/layout.php` |
| `region.head` | html region | Inside <head> (meta tags, analytics) | `app/views/layout.php` |
| `region.header.left` | html region | Header, right of the logo | `app/views/layout.php` |
| `region.header.nav` | list region | Header navigation links (list) | `app/views/layout.php` |
| `region.header.right` | list region | Header, right side: search, new topic, language, theme, notifications, account menu (list) | `core/render.php` |
| `region.header.right.after_search` | html region | Header, right of the search box | `core/render.php` |
| `region.header.right.before_search` | html region | Header, left of the search box | `core/render.php` |
| `region.header.user_menu` | list region | The account list: the header dropdown shows all of it, the sidebar member card the "you" items as shortcuts (list: label, url, icon, count, group "you" or "site", weight, card => false keeps one out of the card) | `core/render.php` |
| `region.header.user_menu.labels` | html region | User dropdown, under the name next to the group label: short labels such as the level (ctx: user) | `app/views/user_menu.php` |
| `region.header.user_menu.stats` | list region | User dropdown numbers strip (list: label, value, url; ctx: user) | `app/views/user_menu.php` |
| `region.main.after` | html region | Below the main content on every page | `app/views/layout.php` |
| `region.main.before` | html region | Above the main content on every page | `app/views/layout.php` |
| `region.main.categories` | list region | Category bar above the list tabs: All + top-level categories (list) | `app/home.php` |
| `region.main.tabs` | list region | Tabs above topic lists (list) | `app/home.php` |
| `region.main.toolbar` | html region | Right of the list tabs; its New Topic button is hidden on wide screens where the member card shows its own | `app/home.php` |
| `region.member.actions` | list region | Buttons of a member: the sidebar member card (New Topic first), the topic author card, the profile card and the account menu, under its numbers (list: label, url, icon, primary, count, title, done, weight; post: the button POSTs to url with the CSRF token and back = the current path; ctx: user, self, place card|author|profile|menu) | `app/views/member_card.php`, `app/views/profile.php`, `app/views/user_menu.php` |
| `region.member.labels` | html region | Short labels after the group label, wherever the core shows a member: the sidebar member card, the topic author card, the account menu and the profile (ctx: user, self, place card|author|menu|profile) | `app/views/member_card.php`, `app/views/profile.php`, `app/views/user_menu.php` |
| `region.member.sections` | list region | Sections beside the lists on a profile, one card each: badges, a calendar, a showcase (list: title, html, url, link, weight; ctx: user, self, place profile) | `app/views/profile.php` |
| `region.member.stats` | list region | Numbers of a member, in the sidebar member card, the topic author card, the account menu strip and the profile card (list: label, value, url, sub, progress 0..1 draws a bar, weight; ctx: user, self, place card|author|menu|profile) | `app/user.php`, `app/views/member_card.php`, `app/views/user_menu.php` |
| `region.points.actions` | list region | Ways to earn on the /points page, as tiles (list: label, title, sub, url, icon, primary, done; ctx: user) | `app/points.php` |
| `region.points.summary` | list region | Short facts next to the balance on the /points page, such as the level (list: label, sub, progress 0..1, url; ctx: user) | `app/points.php` |
| `region.post.actions` | list region | Post action buttons (list, loop, no DB) | `app/views/post.php` |
| `region.post.after` | inline region (loop, no DB) | After each post (loop, no DB) | `app/views/post.php` |
| `region.post.before` | inline region (loop, no DB) | Before each post (loop, no DB) | `app/views/post.php` |
| `region.post.content_after` | inline region (loop, no DB) | After each post body (loop, no DB) | `app/views/post.php` |
| `region.post.meta` | inline region (loop, no DB) | Post meta line, after the time: level, title, badges (loop, no DB) | `app/views/post.php` |
| `region.post.name` | inline region (loop, no DB) | Post name row, after the username, OP and group labels: a short label such as the level (loop, no DB) | `app/views/post.php` |
| `region.sidebar.left.bottom` | html region | Left column, bottom | `app/views/sidebar_left.php` |
| `region.sidebar.left.nav` | list region | Left column navigation links, one per plugin (list: label, url, icon, badge, active, weight; group "community", "tools" or your own id with group_label; past setting nav_visible links the rest fold under More) | `app/views/sidebar_left.php` |
| `region.sidebar.left.top` | html region | Left column, top | `app/views/sidebar_left.php` |
| `region.sidebar.right.bottom` | html region | Right column, bottom | `app/views/sidebar_right.php` |
| `region.sidebar.right.cards` | list region | Right column card stack (list) | `core/render.php` |
| `region.sidebar.right.top` | html region | Right column, top | `app/views/sidebar_right.php` |
| `region.topic.actions` | list region | Topic page action buttons (list) | `app/views/topic.php` |
| `region.topic.header` | html region | Topic page, below the title block | `app/views/topic.php` |
| `region.topic.no_access` | html region | Topic page when a plugin refused the visitor, under the reason | `app/topic.php` |
| `region.topic.replies_after` | html region | After the post stream, before the reply box | `app/views/topic.php` |
| `region.topic.sidebar.bottom` | html region | Topic page right column, bottom | `app/views/sidebar_topic.php` |
| `region.topic.sidebar.cards` | list region | Topic page right column cards (list) | `app/topic.php` |
| `region.topic.sidebar.top` | html region | Topic page right column, top | `app/views/sidebar_topic.php` |
| `region.topic_list.after` | html region | After the topic list, before pagination | `app/views/topic_list.php` |
| `region.topic_list.before` | html region | Between the list tabs and the topic rows (announcements, notices) | `app/views/topic_list.php` |
| `region.topic_list.item.after` | inline region (loop, no DB) | After each topic row (loop, no DB) | `app/views/topic_rows.php` |
| `region.topic_list.item.meta` | inline region (loop, no DB) | In each topic row meta line (loop, no DB) | `app/views/topic_rows.php` |
| `region.topic_list.item.title_suffix` | inline region (loop, no DB) | After each topic title (loop, no DB) | `app/views/topic_rows.php` |
| `region.user.moderate.options` | html region |  | `app/moderation.php` |
| `region.user.profile.actions` | html region |  | `app/views/profile.php` |
| `region.user.profile.after` | html region | Sections inside the profile card, under the numbers: badges (ctx: user, self) | `app/views/profile.php` |
| `region.user.profile.cards` | list region | Cards under the profile card (list) | `app/user.php` |
| `region.user.profile.labels` | html region | Profile card, after the group label: short labels such as a custom title (ctx: user, self) | `app/views/profile.php` |
| `region.user.profile.meta` | inline region (loop, no DB) | Profile card details, after Joined / Seen / website: short items with an icon (ctx: user, self) | `app/views/profile.php` |
| `region.user.profile.stats` | list region | Profile card numbers (list of label, value, url, sub; progress 0..1 draws a bar across the card) | `app/user.php` |
| `region.user.profile.tabs` | list region | Profile page tabs (list) | `app/user.php` |
| `region.user.settings.tabs` | list region | Settings page menu, a section list on phones (list): id => [label, group account|preferences|security|community|developer|more, weight] | `app/user.php` |
| `regions.known` | filter | Register extra regions for Admin → Widgets. | `core/hook.php` |
| `review.decided` | event | Event: a moderator approved (status 1) or rejected (status 2) an item of the review queue (ctx: id, status, by). On approval the usual topic.after_save / post.after_save fire as well. | `app/review.php` |
| `review.held` | event | Event: a topic or reply was put in the review queue (ctx: id, kind, topic_id, post_id, user_id, reason). | `app/review.php` |
| `review.hold` | filter | Filter: why a new topic or reply waits in the review queue (value: the reason so far, an English text shown through t(), or empty; ctx: kind topic|reply, user, category, text). Return a reason to hold it. Administrators and moderators are never held. | `app/review.php` |
| `router.not_found` | filter | No route matched (ctx: path). Return a URL (301) or [url, code] to redirect instead of the 404 page; return nothing to let it 404. Also the place to record misses. | `core/router.php` |
| `router.routes` | filter | Filter: the route table (path => handler). Replace a core address (a portal at /); admin, sign-in, settings, setup and API addresses cannot be taken over. Admin → Plugins lists the takeovers. | `core/router.php` |
| `schema.install` | event | After core tables are created/upgraded. | `core/schema.php` |
| `search.results` | filter |  | `app/search.php` |
| `security.csp` | filter | Content Security Policy directives (name => list of sources): add the CDNs your plugin loads scripts, styles or fonts from. | `core/security.php` |
| `seo.sitemap` | filter | GET /sitemap.xml: return the XML to serve instead of the built-in sitemap (an index, paged files); return nothing to keep the default. | `app/api.php` |
| `topic.access` | filter |  | `app/topic.php` |
| `topic.after_action` | event | After pin/lock/move/restore (ctx: topic_id, action). | `app/topic.php` |
| `topic.after_delete` | event | After a topic was soft-deleted. | `app/moderation.php`, `app/topic.php` |
| `topic.after_save` | event | After a topic was created or edited (ctx: topic_id, post_id, new). A new topic that waits in the review queue fires it when a moderator approves it, so the topic is public by then. | `app/topic.php` |
| `topic.before_save` | filter | New topic data (category_id, user_id, title, body, tags) before insert. | `app/topic.php` |
| `topic.can_reply` | filter |  | `app/topic.php` |
| `topic.category_auto` | filter | Filter: whether a plugin will pick the category of a new topic (bool). True makes the category optional in the composer; return true only when topic.category_missing can actually answer (configured, within limits). | `app/topic.php` |
| `topic.category_missing` | filter | Filter: a new topic arrived without a category (value 0; ctx: title, body, user). Return a category id to file it there (an AI pick, say); the writer must be allowed to post in it. Runs before the default category. | `app/topic.php` |
| `topic.posts` | filter | Filter the posts of the current page (batch-loaded, attach extra data here). | `app/topic.php` |
| `topic.title` | filter | Filter the escaped title html of a topic in lists and on the topic page (ctx: topic, where list|page); loop, no DB. | `app/views/topic.php`, `app/views/topic_rows.php` |
| `topic.view` | filter | Filter the topic row shown on the topic page (ctx: posts). | `app/topic.php` |
| `topic_list.query` | filter | Filter the conditions of every topic list (front page, category, tag, unread): value ["where" => conditions on alias t, "params"]; append to both. Ctx: where, join. | `app/home.php` |
| `topic_list.rows` | filter | Filter topic rows of any list (batch-loaded, attach extra data here). | `app/home.php` |
| `upgrade.after_apply` | event |  | `core/upgrade.php` |
| `upload.after_save` | event | Event: a file was saved under uploads/ (ctx: kind attachment|avatar|site, id, path, file, name, mime, size, is_image, user_id). Copy it to object storage here. | `core/upload.php` |
| `upload.before_save` | filter | Filter: return a message to refuse an uploaded attachment, or change the file at ctx tmp in place (ctx: kind, user, tmp, name, ext, mime, size, is_image). | `core/upload.php` |
| `upload.url` | filter | Filter: the address of an uploaded file (ctx: path), to serve it from a CDN or an object store. Runs for every avatar on a page: no database access. | `core/upload.php` |
| `user.after_delete` | event |  | `app/moderation.php` |
| `user.after_moderate` | event |  | `app/moderation.php`, `app/review.php` |
| `user.after_rename` | event | After a username changed (ctx: user_id, old, new, by). Old profile URLs redirect automatically. | `core/auth.php` |
| `user.after_save` | event | After the profile was saved. | `core/render.php`, `app/user.php` |
| `user.avatar_after` | filter | HTML placed inside the avatar, over its lower corner (ctx: user, size in px); the wrapper is positioned, so use position:absolute. Runs inside lists: no database access. | `core/render.php` |
| `user.before_save` | filter | Profile fields before saving. | `app/user.php` |
| `user.can` | filter | Filter: whether the signed-in member may do something (bool; ctx: permission, user, group), after the group decided. Guests and admins never reach it. | `core/auth.php` |
| `user.email_changed` | event | After a member changed their email address in Settings → Email, or restored the old one from the notice link (ctx: user_id, old, new, restored). | `app/user.php` |
| `user.level` | filter | Filter: the level of a member as a number (ctx: user), read by user_level(). Answered by the plugin that keeps levels. Runs inside lists: no database access. | `core/auth.php` |
| `user.link_after` | filter | HTML appended after every rendered username link (ctx: user, class; class is "profile-name" on the profile header). Runs inside lists: no database access. | `core/render.php`, `app/views/card_newest.php`, `app/views/profile.php` |
| `user.logout_everywhere` | event | After every session of a user was invalidated (ctx: user_id). | `core/auth.php` |
| `user.prefs_save` | filter | Preferences array before saving. | `app/user.php` |
| `user.profile_tab` | filter | HTML for a custom profile tab (ctx: user, tab). | `app/user.php` |
| `user.settings_post` | event | POST handler for a custom settings tab. | `app/user.php` |
| `user.settings_tab` | filter | Extra HTML for a settings tab. | `app/user.php` |



==============================================================================
Core API (docs/API.md)
==============================================================================

Generated by `php flatbb api:list` on 2026-09-30. Every function below is global and available to plugins.

## core/ai.php

- `ai_setting_name(int $n, string $field): string` — The setting that holds one field of connection $n: the first keeps the plain names (ai_provider…), the others ai2_…, ai3_….
- `ai_connections(): array` — The connections that are set up, in the admin's order: n => [n, provider, base_url, model, key].
- `ai_ready(): bool` — Whether a plugin can call ai_chat(): at least one connection is set up.
- `ai_state(): array` — What the site remembers about its connections (data/cache/ai.json): per connection, the last answer, the last failure and a rest.
- `ai_state_update(callable $fn): void` — Change the state under a lock: $fn receives it and returns it changed.
- `ai_order(array $conns): array` — The order to try the connections in: the admin's order, the resting ones last (still tried when all others failed).
- `ai_chat(string $system, string $user, array $opts = []): array` — One question to the model. $opts: purpose (the plugin id, for ai.request / ai.response), max_tokens (the longest answer you expect, default 800), temperature (default 0.2; OpenAI-compatible services only, current Claude models take none), json (true asks for a JSON object where the API supports it), connection (ask only that one, e.g. to test it). Returns ['ok' => true, 'text' => …, 'model' => …, 'connection' => n, 'usage' => ['in' => n, 'out' => n]] or ['ok' => false, 'error' => …] after every connection failed. Never call it from a loop or inside a transaction: it is an HTTPS request that may take seconds (more when it falls back).
- `ai_call(array $c, string $system, string $user, array $opts, int $timeout): array` — Ask one connection. The answer as ai_chat() gives it, and on failure 'status' (HTTP code, 0: no answer) and 'rest' (its own fault).
- `ai_note(int $n, array $r): void` — Remember how a connection did: the last answer, the last failure and, when the failure was its own, a rest.
- `ai_json(string $text): ?array` — The first JSON object in an answer (models like to wrap it in ```json fences or a sentence), or null.
- `ai_http(string $url, array $headers, string $body, int $timeout): array` — POST JSON over HTTPS with the certificate checked: [status, decoded body or null, error].

## core/auth.php

- `secret(): string`
- `uid(): int`
- `me(): ?array` — Current user row or null. Cached per request.
- `auth_signature(int $uid, int $exp, string $password_hash): string`
- `login_pending_set(array $user, bool $remember): void` — Pending login: the password was right but a plugin asked for a second step (account.login_challenge). The user id travels in a signed five-minute cookie; login_pending_user() gives the user back to the plugin that finishes the sign-in.
- `login_pending_user(): ?array` — ['user' => row, 'remember' => bool] for a valid pending cookie, else null.
- `login_pending_clear(): void`
- `auth_key(array $user): string` — What a session signature is bound to: the password hash plus a per-user salt (changing either signs out every device).
- `user_logout_everywhere(int $id): void` — Sign a user out on every device by rotating the salt; the password stays. Fires user.logout_everywhere.
- `login_user(array $user, bool $remember = true): void`
- `logout_user(): void`
- `visitor_token(): string` — Visitor token cookie used as CSRF seed for guests and members alike.
- `csrf_token(): string`
- `csrf_field(): string`
- `check_csrf(): void` — Verified once per request by dispatch() for every POST; handlers may still call it (no-op the second time).
- `require_post(): void` — POST only + CSRF.
- `need_login(): array`
- `need_admin(): array`
- `need_mod(): array`
- `groups(): array`
- `group_by_id(int $id): ?array`
- `my_group(): ?array`
- `is_admin(): bool`
- `is_mod(): bool`
- `can(string $permission): bool` — can('post'), can('reply'), can('upload'), can('edit_own'), can('delete_own'). The member's group decides, then plugins may change the answer through the filter user.can (ctx: permission, user, group): a trust level that allows links, a time-out that forbids replies. Guests have no permissions and admins always have every one; no plugin changes either.
- `group_allowed(string $csv, bool $guest_ok = true): bool` — Group id list from a comma separated setting; empty means "everyone".
- `user_by_id(int $id): ?array` — Full user row (all columns). Cached per request under 'users_full'.
- `user_by_name(string $name): ?array`
- `users_cache_put(array $users, string $bucket = 'users'): void`
- `user_public_columns(): string` — Public user columns safe to render anywhere (no password/email).
- `user_level(array $user): int` — A member's level as a number, 0 when no plugin keeps levels. The plugin that does answers the filter user.level (ctx: user); others (badges, gated topics) read it here instead of calling that plugin. Runs inside lists: the answer must not query.
- `users_by_ids(array $ids): array` — Batch-load users (public columns) for a list of ids, keyed by id. Never call in a loop.
- `username_rule(): array` — Username length rule from the settings: [min, max] (letters, numbers, dot, dash, underscore; the first character a letter or digit).
- `username_valid(string $name): bool`
- `username_rule_text(): string` — The message for a username that breaks the rule.
- `password_min(): int` — Shortest password the settings allow.
- `password_check(string $pass): string` — '' when the password is long enough, else the message.
- `user_rename(array $user, string $new, int $by = 0): string` — Change a username: same rules as registration, the old name is kept so /u/<old name> redirects, caches are cleared and user.after_rename fires. Returns '' on success or the error message to show. $by is the acting user (admin or self).
- `display_names_on(): bool` — Whether members may have a display name (Settings → Registration). While off, every name shows as the username.
- `display_name_clean(string $name): string` — A display name as it is stored: invisible and control characters removed, spaces collapsed. '' clears it.
- `display_name_check(array $user, string $name): string` — '' when $name (cleaned) may be the display name of $user, else why not: at most 30 characters, nobody else's username.
- `display_name_set(array $user, string $name): string` — Set a member's display name ('' clears it; the same as the username clears it too). Returns '' or the error to show. The profile address, sign-in and @mentions keep using the username.
- `user_former_names(array $user): array` — Previous usernames of a user, oldest first: [['name' => ..., 'at' => unix], ...].
- `user_by_former_name(string $name): ?array` — The user who used to have this name (old profile links redirect to the current name).
- `user_create(string $username, string $email, string $password, int $group_id = 0): int`
- `user_url(array|string $user): string`
- `post_wait_seconds(array $user): int` — Rate limit helper: returns seconds the user must still wait before posting.
- `post_hold(array $user): array` — When this member may post again: ['until' => unix seconds, 'seconds' => how many are left, 'reason' => 'interval'|'new_user', 'message' => what to tell them]. An empty array means right now. Moderators are never held back.
- `human_wait(int $seconds): string` — A waiting time in words: "12 seconds", "3 minutes", "2 hours 5 minutes".
- `new_user_limited(array $user): bool`

## core/boot.php

- `config(?string $key = null, mixed $default = null, ?array $override = null): mixed` — Site configuration from data/config.php. Empty array before installation.
- `is_installed(): bool`
- `debug_mode(): bool`
- `app_boot(): void` — Boot the application for a web request or CLI command.

## core/cron.php

- `cron_jobs(): array`
- `cron_interval(array $job): int`
- `cron_run(bool $force = false): array` — Run every due job. Returns [name => status].
- `cron_run_web(): never`
- `cron_hot_scores(): string` — Recompute hot_score for topics active in the last 30 days.
- `cron_cleanup(): string`
- `cron_stats(): string`
- `cron_update_check(): string` — Daily: refresh the core release cache so the admin menu shows "Updates · new" without anyone clicking Check.

## core/db.php

- `db(): PDO`
- `db_driver(): string`
- `db_is_mysql(): bool`
- `db_connect(array $c): PDO` — Open a connection from a config array. Used by db() and by the installer.
- `sql_query_count(bool $inc = false): int`
- `q(string $sql, array $params = []): PDOStatement` — Run a prepared query.
- `one(string $sql, array $params = []): ?array`
- `all(string $sql, array $params = []): array`
- `val(string $sql, array $params = []): mixed`
- `col(string $sql, array $params = []): array` — First column of every row.
- `tx(callable $fn): mixed` — Run $fn inside a transaction; nested calls join the outer transaction.
- `sql_marks(int $count): string`
- `rows_by_ids(string $table, array $ids, string $cols = '*', string $key = 'id'): array` — Fetch rows by primary key, keyed by id. Chunks large id lists.
- `db_like(string $s): string` — LIKE pattern for user input, to be used with `LIKE ? ESCAPE '!'` (a backslash escape character is a syntax error on MySQL).
- `db_insert(string $table, array $data): int`
- `db_update(string $table, array $data, string $where, array $params = []): int`
- `db_delete(string $table, string $where, array $params = []): int`
- `db_upsert(string $table, array $data, array $keys): void` — Insert or update on conflict of $keys (must be PK or UNIQUE).
- `db_insert_ignore(string $table, array $data): bool` — Insert unless a unique key already exists. Returns true when inserted.
- `db_increment(string $table, string $column, int $delta, string $where, array $params = []): void` — Atomic counter update: db_increment('fb_topics', 'view_count', 1, 'id=?', [$id])
- `db_types(): array` — Portable column types. Use these names in db_create_table()/db_ensure_columns(): id, uint, int, bigint, bool, float, string (255), key (191, indexable), text, mediumtext.
- `db_column_sql(string $name, string $type): string`
- `db_create_table(string $table, array $columns): void` — Create a table if missing. $columns: ['id' => 'id', 'title' => 'string', 'body' => 'text', 'flag' => 'TINYINT NOT NULL DEFAULT 1']. Raw SQL types are allowed but must work on both engines.
- `db_drop_table(string $table): void`
- `db_table_exists(string $table): bool`
- `db_columns(string $table): array`
- `db_ensure_columns(string $table, array $columns): void` — Add any missing columns (idempotent).
- `db_drop_column(string $table, string $column): void`
- `db_index_exists(string $table, string $index): bool`
- `db_create_index(string $table, string $index, array $columns, bool $unique = false): void` — db_create_index('fb_posts', 'idx_posts_topic', ['topic_id','id'])
- `db_drop_index(string $table, string $index): void`
- `db_create_fulltext(string $table, string $index, array $columns): void` — Full-text index for MySQL; SQLite uses FTS5 virtual tables (see search.php).
- `db_greatest(string $a, string $b): string` — Portable "greatest of two expressions".
- `db_random(): string` — Portable random ordering.

## core/devtools.php

- `plugin_check(string $id): array` — Static checks a plugin must pass before it is accepted by the marketplace.
- `shell_exec_safe(array $cmd): ?string`
- `plugin_package(string $id): string` — Zip plugins/<id>/ into dist/<id>-<version>.zip. Returns the zip path.
- `plugin_publish(string $id, string $token, string $changelog = '', string $endpoint = '', bool $insecure = false, array $images = [], bool $confirm_other = false): array` — $images: screenshots to publish with the version, each ['path' => local file, 'name' => file name] (up to 5; they replace the plugin's set on the marketplace).
- `lang_keys(): array` — Every t('...') source string in core/ and app/ (sorted).
- `lang_sync(string $code): array` — Write lang/<code>.php: keeps existing translations, adds missing keys with an empty value, drops keys that no longer exist. Returns [added, removed, untranslated].
- `security_files(string $dir): array` — PHP files under a directory (recursive).
- `security_scan(array $files): array` — Rules every file must pass (core, app and plugins alike). Returns "file:line: message" findings. - no direct $_GET/$_POST/$_REQUEST/$_COOKIE reads (use post_str/post_int/post_list/post_secret/get_str/get_int) - no eval/exec/system/passthru/shell_exec/popen/proc_open/unserialize - in views: <?= ... ?> may only start with h(), t(), raw() or another known safe helper, never a bare variable
- `test_boot(): void` — Minimal test runner, no dependencies: every tests/*.php file defines test_* functions that throw on failure. test_boot() gives each run a fresh SQLite database in the system temp dir, so tests never touch data/.
- `test_assert(bool $ok, string $message = 'assertion failed'): void`
- `test_same(mixed $expected, mixed $actual, string $what = 'value'): void`
- `test_contains(string $needle, string $haystack, string $what = 'output'): void`
- `test_not_contains(string $needle, string $haystack, string $what = 'output'): void`
- `test_run(string $only = '', ?callable $out = null): array` — Run every test_* function found in tests/*.php (or one file). Returns [passed, failed, [failures]].

## core/helpers.php

- `raw(string $html): string` — Marks HTML that is intentionally output unescaped (already-rendered fragments). security:check allows only raw() and known helpers after <?=
- `h(string|int|float|bool|null $s): string`
- `now(): int`
- `cut(string $s, int $max = 100, string $suffix = '…'): string` — Truncate a UTF-8 string with an ellipsis.
- `human_time(int $ts): string` — "3 minutes ago" style relative time.
- `tz_options(): array` — Time zone choices for Settings → General: identifier => 'Region/City (UTC+03:00)'.
- `human_size(int $bytes): string`
- `human_number(int $n): string`
- `slugify(string $s, int $max = 80): string` — URL slug from a title: "Hello World!" -> "hello-world".
- `json_decode_array(?string $json, array $default = []): array`
- `json_encode_value(mixed $value): string`
- `random_token(int $bytes = 16): string`
- `client_ip(): string` — The visitor's address; behind a trusted proxy (setting trusted_proxies) the forwarded address. See core/security.php.
- `is_post(): bool`
- `is_ajax(): bool`
- `post_str(string $key, int $max = 65535): string`
- `post_secret(string $key, int $max = 4096): string` — Untrimmed POST string (passwords).
- `post_list(string $key, int $max = 200): array` — POST list of scalars as strings (checkbox groups, multi-selects).
- `post_int(string $key, int $default = 0): int`
- `get_str(string $key, int $max = 500): string`
- `get_int(string $key, int $default = 0, int $min = PHP_INT_MIN, int $max = PHP_INT_MAX): int`
- `json_response(array $data, int $status = 200): never` — Send JSON and stop.
- `json_ok(array $data = []): never`
- `json_error(string $message, int $status = 400, array $extra = []): never`
- `redirect(string $url, int $status = 302): never` — Redirect and stop.
- `undo_keep(string $scope, string $value): void` — The value a removal replaced, kept on the server for a minute so the toast's Undo can bring it back (field_action()). $scope names what was removed ("setting:site_favicon", "avatar:12"); the value never goes to the browser.
- `undo_take(string $scope): ?string` — Take back what undo_keep() kept for this member and scope, once, within a minute; null when it is gone.
- `flash(string $message, string $type = 'success'): void` — Flash message stored in a short-lived cookie (no server sessions).
- `flash_take(): ?array`
- `cookie_str(string $name, int $max = 200): string` — A cookie value, trimmed to $max characters ('' when absent).
- `app_cookie(string $name, string $value, int $expires, bool $httponly = true): void`
- `is_https(): bool`
- `error_page(string $message, int $status = 400, string $title = ''): never` — Show an error page and stop.
- `not_found(string $message = ''): never`
- `forbidden(string $message = ''): never`
- `fail(string $message, string $back = ''): never` — Abort POST handlers with a message; AJAX gets JSON, browsers get a flash + back.
- `request_cache(string $key, ?callable $fn = null, bool $reset = false): mixed` — Per-request memo cache. request_cache('key', fn() => ...)
- `setting(string $key, string $default = ''): string` — Read a site setting (fb_settings). All settings are cached per request.
- `settings_all(): array`
- `setting_defaults(): array`
- `save_settings(array $values): void`
- `mail_send(string $to, string $subject, string $text): bool` — Send a plain-text email. Plugins take over through the mail.send hook (return true when sent, false when failed); without a plugin PHP's mail() is used. Returns whether the mail was accepted.
- `http_exec_prefer_local(CurlHandle $ch, string $url): string|false` — A request from this site to its own host (www.flatbb.com asking its own marketplace, the deployment self-check) goes to the web server on this machine directly: the host name is resolved to 127.0.0.1, so a CDN or proxy in front (Cloudflare challenges a server talking to itself) is not in the way. When nothing answers on the loopback, the normal route is used. $ch is configured and ready; returns what curl_exec() returns.
- `http_self_request_blocked(string $url): bool`
- `paginate_calc(int $total, int $page, int $per_page): array` — Simple ordered pagination info.

## core/hook.php

- `hook_registry(?array $set = null): array`
- `hook_add(string $name, callable|string $callback, string $plugin = '', int $priority = 10): void` — Register a callback at runtime (core modules and plugins loaded from manifests).
- `hook_has(string $name): bool`
- `hook(string $name, mixed $value = null, array $ctx = []): mixed`
- `fire(string $name, array $ctx = []): void`
- `regions_known(): array` — Every layout region the core renders, with a short description (used by Admin → Widgets and docs).
- `region(string $name, array $ctx = [], string $default = '', bool $wrap = true): string` — HTML region. $default is the core content (may be empty). $wrap=false returns raw HTML (for <head>, scripts).
- `region_visible(string $html): bool` — Whether region HTML shows anything: hidden inputs, scripts and empty containers do not count. A template uses this before it draws a frame around a region ("More options" in the composer), so a plugin that only plants a hidden field or a script does not leave an empty box behind.
- `region_list(string $name, array $items, array $ctx = []): array` — Array-valued region (nav links, tabs, cards, menu items). Items are keyed by id; besides label/url/icon/html an item may carry - weight (int, default 0): lower comes first, equal weights keep insertion order - visible ('everyone' default | 'members' | 'admins'): filtered here, once, for every plugin - new_tab (bool): links open in a new tab where the template supports it Admins can hide single items per region and put them in any order in Admin -> Widgets (settings layout_hidden_items, layout_item_order).
- `layout_order_items(string $region, array $items): array` — Sort the items of a list region: the order an admin saved in Admin → Widgets comes first (in that order), then everything else by weight, then registration order. Plain HTML items count as weight 0.
- `layout_item_order(string $region): array` — Item ids an admin ordered in one list region, first to last (setting layout_item_order: {region: [id, …]}).
- `layout_core_items(string $region): array` — The items the core itself puts into a list region (label only), so Admin → Widgets can order them next to plugin items.
- `layout_hidden_items(string $region): array` — Items an admin hid in one list region: id => 1 (setting layout_hidden_items: {region: {id: 1}}).
- `menus_known(): array` — The list regions an admin edits as menus, region => name. Their items can get new text, a new link, an icon and a new tab, and the admin adds links of their own; hiding and order are the same settings Admin → Widgets writes. Plugins add a menu of their own through the filter menus.known.
- `menu_edits(string $region): array` — The admin's edits of one menu, item id => [label, url, icon, new_tab, group, custom, weight] (setting menu_items: {region: {id: {…}}}).
- `menu_href(string $url): string` — A link an admin typed: a path on this site ("/growth?tab=x") goes through url(), a full http(s) address stays, anything else is ''.
- `menu_apply(string $region, array $items): array` — A menu with the admin's edits applied: new text, link, icon or tab for any item (core, plugin or custom), and the custom links added.
- `layout_blocks(): array` — Admin-managed HTML blocks stored in setting layout_blocks: [{id,region,title,html,enabled,sort}]
- `layout_blocks_html(string $region, bool $wrap = true): string`
- `layout_plugin_enabled(string $hook, string $plugin): bool` — Admin can switch a plugin off in a specific region (setting layout_regions: {"region.x": {"plugin": 0}}).

## core/icons.php

- `icon_set(): array` — The built-in icons with what plugins add (filter icon.paths): name => SVG inner markup. Once per request.
- `icon_value_ok(string $v): bool` — Whether a value is one an icon field may store.
- `icon_library(): array` — The shared library of uploaded icons (uploads/site/icon_*), newest first: every icon field offers them under Your icons.
- `icon_library_add(array $file): string` — Add an uploaded icon to the library (named by its content, so the same file is kept once; an SVG is cleaned). Returns its value.
- `icon_from_post(string $name, string $current = ''): string` — The value an icon field posted: an uploaded file (field <name>_file) joins the library and wins, else the picked value when it is valid, else $current. Throws RuntimeException with a message for the admin when the upload is refused.
- `icon_picker(string $name, string $value, array $opts = []): string` — The icon field of every form: a button with the current icon that opens a panel with a search box, the built-in icons, Your icons (the shared library), an emoji box and an upload (the form needs enctype="multipart/form-data"). Opts: keep => the label of a first tile that keeps what the item has (value ""; "none" then removes the icon), none => false to leave out "No icon", upload => false for no file input. app.js wires the tiles, the search and the emoji box ([data-icon-picker]).
- `icon_paths_more(): array`

## core/import.php

- `importers(): array` — Importer plugins, installed or enabled: plugin id => ['plugin', 'from', 'label', 'description', 'page', 'enabled', 'name', 'version'].
- `importer(string $key): ?array` — The importer for a source name ('flarum') or a plugin id.
- `import_job(): array` — The current or last job ([] when none): plugin, status (running|done|failed|cancelled), phases, phase, cursor, data, secret, notes, ...
- `import_job_save(array $job): void`
- `import_ready(): array` — Whether this forum may receive an import: ['ok' => bool, 'reason' => text, 'members', 'topics', 'categories', 'tags']. A new forum has one member (you) and the starter content, which the import replaces. After an import, starting again is allowed too: the members and content of the last import are removed first.
- `import_start(string $plugin, array $phases, array $data = [], array $secret = []): void` — Start a job for an importer plugin. $phases: [['key' => 'users', 'label' => 'Members', 'total' => 3412], ...] in the order they run (labels are English source text, shown through t()). $data is the importer's own state; $secret holds what must not outlive the job (a database password): it is removed when the job ends.
- `import_reset(int $keep_user = 0): void` — Empty the content tables for an import: topics, posts, categories, tags, likes and everything hanging off them. $keep_user > 0 (starting again after an import) also removes every member except that one.
- `import_phase_next(array $job): array` — Move a job to its next phase (importers return this from their step when the phase has no rows left).
- `import_note(string $text): void` — A line for the report ("12 posts were empty and skipped"). Collected per batch and saved with the job.
- `import_run(float $seconds = 8.0): array` — Run the job for about $seconds: batch after batch, each in one transaction with the saved job. Returns the job. Two callers at once (two tabs, the page and the command line) are kept apart by a lock: the second one just reads.
- `import_done(array $job): array`
- `import_cancel(): void` — Stop a job: what was imported stays, the secret goes. Starting again removes it and begins anew.
- `import_retry(): void` — Pick up a failed job where it stopped.
- `import_phase_counts(array $job): array` — Counters of everything that was imported, in a few set-wise statements.
- `import_phase_search(array $job): array` — The search index, 500 posts a batch.
- `import_username(string $name, string $fallback): string` — A username that follows the site's rule and is not taken: "Jane Doe" → "Jane_Doe", "_x" → "x", taken → "name_2".
- `import_slug(string $table, string $slug, string $fallback): string` — A slug that is not taken in $table ('fb_categories', 'fb_tags').
- `import_user(array $u): int` — Add a member. $u: username, email, password (a bcrypt/argon hash from the source, kept so members sign in as before), display_name, group_id, avatar_file (a picture on this server, stored as the avatar), bio, created_at, last_seen, status (0 banned), email_verified, old_id (for fallbacks). A member whose email is already here (you) is matched to that account instead. Returns the member's id.
- `import_group(string $name, string $color = ''): int` — The id of a group by slug, created with the members' permissions when missing.
- `import_category(array $c): int` — Add a category: name, slug, description, color, parent_id, sort, is_hidden, view_groups, post_groups. Returns its id.
- `import_tag(array $tag): int` — Add a tag: name, slug. Returns its id (an existing tag with the same name is reused).
- `import_topic(array $t): int` — Add a topic without its posts: id (optional, keeps the source id), category_id, user_id, title, created_at, is_pinned, is_locked, is_deleted (0, 1 deleted, REVIEW_PENDING waiting for approval), view_count, tags (tag ids). Its first post, last reply and counts come from import_post() and the Counters phase.
- `import_post(array $p): int` — Add a post (Markdown body): id (optional), topic_id, user_id, floor (0 = the topic's first post), body, created_at, reply_to_id, edited_at, edited_by, is_deleted (0, 1, REVIEW_PENDING), created_ip. A waiting post also gets its row in the review queue. Returns the post id.
- `import_like(int $user_id, int $post_id, int $topic_id, int $at = 0): void` — A like of a post.
- `import_read(int $user_id, int $topic_id, int $last_post_id, int $at = 0): void` — How far a member has read a topic (keeps imported topics from all showing as unread).
- `import_copy_file(string $file, string $dir): string` — Copy a file of the source forum into uploads/import/<dir>/ under a new name. Pictures only, plus common documents and archives; never anything a server could run. Returns its public URL ('' when the file is missing or refused).
- `import_cli(string $from, array $opts, callable $out): int` — php flatbb import [from] [--options]: without a source, list the importers and the job. With one, continue its job, or start a new one through the importer's cli callback (its --options), then run it to the end. Returns the exit code.

## core/lang.php

- `lang_code(): string`
- `lang_site_code(): string` — The administrator's default language (Settings → General), before any per-visitor choice.
- `lang_installed(string $code): bool`
- `lang_set(string $code, ?array $prefs = null): void` — Remember the language: a cookie for everyone, the preferences for a member. $prefs: the preferences the caller has just written. Settings → Preferences passes them so this does not save the row it read at the start of the request over the answers just given (theme, notifications, points would jump back). Without them the row is read again here, never from the request's cached copy.
- `lang_available(): array` — Installed language packs: code => native name (English always first).
- `lang_direction(): string` — 'rtl' when the active pack declares '__dir' => 'rtl' (Persian, Arabic, Hebrew…), else 'ltr'.
- `lang_table(): array`
- `lang_add(array $strings): void` — Merge a plugin's translations at runtime.
- `t(string $key, mixed ...$args): string`

## core/links.php

- `link_previews_on(): bool`
- `link_hash(string $url): string` — The key of a URL: trimmed, without its fragment.
- `link_previewable(string $url): bool` — Whether a URL may get a card: http(s) to another site, not a picture, a video, a file or a video site (those have their own players), not on the blocked list. Filter link.previewable (bool; ctx url, host) lets a plugin take a link over.
- `link_standalone(string $html, int $max = 3): array` — The links that stand in a paragraph of their own in rendered post HTML, in order, at most $max.
- `link_previews(array $urls, bool $queue = true): array` — The stored previews of these URLs that are ready, url => row, in one query; the ones never seen are queued in one insert and fetched after this request (or by the scheduled job). Lists and topic pages call it once per page.
- `link_queue_post(string $html): void` — Queue the stand-alone links of a post that was just written, so its cards are ready before anyone opens it.
- `link_fetch_soon(): void` — After the response is sent (PHP-FPM), fetch what this request queued; elsewhere the scheduled job does it.
- `link_fetch_job(): string` — Scheduled job core.link_previews: fetch pending links, retry failed ones (up to three tries a day apart), refresh old ones.
- `link_fetch_pending(int $limit): int`
- `link_fetch(string $url): ?array` — Fetch and read one page: ['title', 'description', 'image', 'site_name', 'card' large|small|text] or null.
- `link_public_ip(string $ip): bool` — A public address: not loopback, private (RFC 1918, fc00::/7), link-local, CGNAT, reserved or IPv4 mapped in IPv6.
- `link_resolve(string $host): ?string` — The one address to connect to for a host, when every address it resolves to is public; null otherwise.
- `link_http_get(string $url, int $hops = 0): ?array` — GET a page with every check in the file header: ['url' => final url, 'type' => content type, 'body' => up to 512 KB] or null.
- `link_absolute(string $ref, string $base): string` — An address from a page made absolute against the page's own; '' unless it ends up http(s).
- `link_parse(string $html, string $url, string $type = ''): ?array` — Read the card from a page's HTML: Open Graph first, then Twitter cards, then the plain title and description.
- `link_card_html(array $p, string $url): string` — The card: a link to the page with its picture (when it has one), its site, title and description. Everything escaped.
- `link_cards_in_posts(array $posts): array` — The topic page: a link on a line of its own becomes its card (three per post), the cards of the whole page in one query.

## core/manifest.php

- `plugin_manifest_parse(string $src, ?string &$error = null): ?array`
- `plugin_manifest_value(array $toks, int &$p): mixed` — One value at $p (advances $p past it).
- `plugin_manifest_atom(array $toks, int &$p): mixed`
- `plugin_manifest_array(array $toks, int &$p, string $close): array` — Array items up to the closing bracket (advances $p past it).
- `plugin_manifest_string(string $literal): string` — The value of a single- or double-quoted string literal (double quotes with variables are not constant strings and never reach here).
- `plugin_manifest_at(array $toks, int $p): string`

## core/markdown.php

- `md(string $text): string`
- `md_fences(string $text, array &$ph, bool $escaped): string` — Replace fenced and inline code with placeholders. $escaped: whether $text already went through h().
- `md_blocks(array $lines, array &$ph = []): string`
- `md_list(array $lines, int &$i): string` — Parse a list starting at $i (advances $i to the last list line). Two nesting levels.
- `md_table(array $lines, int &$i): string`
- `md_inline(string $s): string` — Inline formatting on already-escaped text.
- `md_link(string $url, string $label_html): string`
- `md_safe_url(string $url, bool $image = false): string` — Allow http(s), mailto and site-relative URLs only.
- `md_mentions(string $text): array` — Extract usernames mentioned in a markdown source.
- `md_excerpt(string $text, int $max = 160): string` — Plain-text excerpt of a markdown source.
- `posts_rerender(): int` — Render every post's Markdown again into body_html (in slices, so a big forum fits the request). Returns the number of posts.

## core/migrate.php

- `migrate_import_sqlite(string $file, ?callable $progress = null): array` — Returns a report array: ['tables' => [name => rows], 'skipped' => [name => reason], 'seconds' => float].
- `migrate_create_table_like_sqlite(PDO $src, string $table): bool` — Create a plugin table in the destination by translating SQLite column types.

## core/plugin.php

- `plugin_id_valid(string $id): bool`
- `plugin_path(string $id, string $file = ''): string`
- `plugin_url(string $id, string $file): string` — Public URL for a static file inside plugins/<id>/assets/.
- `plugins(bool $refresh = false): array` — Registered plugins keyed by id (rows from fb_plugins with decoded JSON).
- `plugin_enabled(string $id): bool`
- `plugin_manifests(?array $set = null): array` — Live manifests of loaded plugins (from plugin.php, not the DB snapshot).
- `plugin_manifest(string $id): ?array`
- `plugin_peek(string $id): ?array` — Read id, name, version, description and author of plugins/<id>/plugin.php as text, without executing it. Used for plugins that are not enabled: their code only runs once an admin enables them.
- `plugin_read_manifest(string $id): ?array` — Include plugins/<id>/plugin.php once and return its manifest (null when invalid). Only call this for enabled plugins or on an explicit admin/CLI action.
- `plugins_load(): void` — Load every enabled plugin and register what it declares. Called from app_boot().
- `plugin_settings_schema(string $id): array`
- `plugin_settings(string $id): array` — All settings for a plugin, defaults from the manifest merged with saved values.
- `plugin_setting(string $id, string $key, mixed $default = null): mixed`
- `plugin_save_settings(string $id, array $settings): void`
- `plugin_settings_from_post(string $id, array $post): array` — Validate submitted values against the manifest schema. Unknown keys are dropped.
- `plugin_settings_form(string $id): string` — Render admin form fields from the manifest schema.
- `plugin_sync(): array` — Scan plugins/ and update fb_plugins. New plugins are registered disabled.
- `plugin_enable(string $id): void` — Enable a plugin. The request that uploaded a new zip still holds the previous manifest (plugin_read_manifest() caches per request), so the version recorded here stays the old one on purpose: plugin_sync() on the next request sees the difference, runs the install routine with the new code loaded and records the new version (see plugin_sync_pending).
- `plugin_disable(string $id): void`
- `plugin_uninstall(string $id): void` — Run the plugin's uninstall callback (drops its tables) and forget its settings. Files stay.
- `plugin_delete_files(string $id, bool $forget = true): void` — Remove plugins/<id>/ from disk. $forget=false keeps the registry row (settings, enabled state) for a reinstall.
- `plugin_install_zip(string $file): string` — Install (or replace) a plugin from a zip that contains exactly one top-level folder <id>/ with plugin.php. Used by the admin upload form and by the marketplace client. Returns the plugin id; throws RuntimeException.
- `plugin_assets_build(): void` — Concatenate CSS/JS declared by enabled plugins into data/cache/plugins.css|js. 'assets' => ['css' => 'my_css_function' | 'assets/style.css' | [..], 'js' => ...]
- `plugin_assets_tag(string $type): string`
- `plugin_assets_serve(string $type): never`

## core/points.php

- `points_rules(): array` — The rule table: defaults from the core and the plugins (hook points.rules), numbers and switches from Admin → Points.
- `points_rule(string $id): ?array`
- `points_day_start(): int` — Midnight today, for the daily caps.
- `points_award(int $user_id, string $rule_id, int $ref_id = 0, string $note = ''): bool` — Give a member what a rule says: nothing when the rule is off or worth 0, when the daily cap is reached, or when this object (a post, a day, a task) already paid this member under the same rule. Returns true when points moved.
- `points_revoke(int $user_id, string $rule_id, int $ref_id, string $reason, string $note = ''): bool` — Take back what a rule paid for an object (a like undone, a post deleted): only what was really paid, once.
- `points_reasons(): array`
- `points_label(string $reason): string`
- `points_add(int $user_id, int $delta, string $reason, int $ref_id = 0, string $note = ''): bool` — Change a user's balance. Returns false when vetoed or nothing to do.
- `points_of(int $user_id): int`
- `points_log(int $user_id, int $page = 1, int $per_page = 30, string $kind = ''): array` — ['rows' => [...], 'pagination' => paginate_calc()]; $kind '' = everything, 'in' = earned, 'out' = spent. Rows carry label and url.
- `points_month(int $user_id): array` — [earned, spent] this month, as positive numbers.
- `points_log_links(array $rows): array` — Adds 'label' and 'url' to ledger rows in two queries at most: reasons about a post (reply, like…) link to the post, "topic" to the topic; plugins answer for their own reasons through points.ref_url.
- `points_top(int $limit = 10): array` — Top balances (public columns). Used by leaderboard plugins.
- `points_public(array $user): bool` — Whether a user shows the balance on the public profile (preference, default on).
- `points_log_html(array $rows, string $empty = '', bool $by_day = false): string` — HTML list of history rows (the /points page, Settings → Points and the admin user drawer): an icon for earned or spent, what happened and where with the time under it, the amount in a column of its own. $by_day puts a Today / Yesterday / date line above each day.
- `points_icon(string $reason, int $delta): string` — The icon of a history line, by reason; plugins answer for their own reasons through the points.icon filter. Memory only.

## core/render.php

- `view(string $view, array $vars = []): string`
- `view_render(string $__file, array $__vars): string` — Include one template file with $vars extracted and return its output (buffers are unwound when it throws).
- `page(string $title, string $main, array $opts = []): never`
- `sidebar_cards_default(): array` — Default right-column cards; plugins add through region.sidebar.right.cards.
- `slot(string $name, array $ctx = [], string $default = ''): string` — Lightweight inline slot for positions rendered inside loops (topic rows, posts). Runs the hook "region.<name>" but skips admin HTML blocks and uses a <span>. Hook callbacks here must not query the database (see docs/PLUGIN.md, N+1 rule).
- `site_stats(): array`
- `bio_html(string $bio): string` — A member's bio as HTML: escaped plain text (no markdown) with @mentions linked to the profiles.
- `card(string $title, string $body, string $class = '', string $extra = ''): string`
- `logo_mark(int $size = 28, string $class = ''): string` — Default logo mark (rounded square, F + dot). Fill follows --brand; assets/favicon.svg is the same drawing.
- `logo_style(): string` — How the header shows the site (Admin → Settings → General → Logo): icon (the icon and the site name), image (the uploaded logo) or name (the name alone). Unset, a site with an uploaded logo keeps showing it; "image" without an uploaded logo falls back to the icon.
- `site_icon_html(int $size = 28): string` — The site's icon: the uploaded square icon, or the default mark.
- `site_logo_html(): string` — The header's link home in the chosen style. The full logo swaps to its dark version on dark themes, and on phones gives way to the icon when there is one; the name next to the icon hides on phones unless the admin keeps it.
- `image_field(string $name, string $value, array $opts = []): string` — A picture setting: the picture (or $opts['empty'], a placeholder), Upload / Replace and Remove. With $opts['action'] (a URL answering field_action()) a picked file is saved at once and Remove acts at once with an Undo; without JavaScript the file goes with the form and a Remove box does the rest ($name . '_remove'). $opts: action, accept (extensions), label (for the toasts), empty (placeholder HTML), wide, url (the picture's own URL when $value is not an upload path), attr (extra attributes for the file input).
- `secret_field(string $name, string $saved, array $opts = []): string` — A saved key: "Saved · ends in …ab12" with Change and Remove (at once, with an Undo, through $opts['action']), or the input when none is saved or Change was pressed. A new key goes with the form's Save; empty keeps the saved one.
- `field_action(string $scope, string $current, callable $store, ?callable $upload, string $label): never` — The server half of image_field() / secret_field(): POST do = upload (the file in "file") | remove | restore. $store writes the new value ('' removes); $upload returns the stored path of the uploaded file (RuntimeException: its message is shown); $scope keys the Undo. Answers JSON: ok, message, url (pictures).
- `avatar_field_action(array $user): never` — field_action() for a member's avatar: $user's picture, saved or removed at once; the letter avatar when empty.
- `avatar_field(array $user, string $action, int $size = 96): string` — The avatar as an image_field(): round, the letter avatar as its placeholder.
- `asset_url(string $file): string` — Address of a core asset (app.css, app.js) with a version that changes whenever the file's content changes, so browsers and proxies fetch a re-uploaded file: FLATBB_VERSION, a short hash of the file, and the stamp Tools -> Clear cache sets.
- `icon(string $name, string $class = ''): string`
- `icon_paths(): array`
- `avatar(?array $user, int $size = 32, bool $link = true, bool $marks = true): string` — Avatar image or letter fallback. $user needs id, username, avatar (and display_name for the letter and the tooltip). $marks = false leaves out the online dot and the plugins' corner marks (a picture field's placeholder).
- `user_online(array $user): bool` — Whether a member counts as online: seen in the last 15 minutes (the window the Statistics card uses) and the setting online_dot is on.
- `time_tag(int $ts, string $fmt = 'rel', string $class = ''): string` — <time> for a unix timestamp. The server prints the site time zone (relative text, or an absolute date after a month) with the absolute time as tooltip; app.js re-renders both in the visitor's own time zone. $fmt: rel | date (Sep 7, 2026) | month (Sep 2026) | full (2026-09-07 14:05).
- `post_hold_notice(array $hold): string` — "You can post again in 3 minutes" with a clock that runs down and puts the form back when it reaches zero. Under ten minutes it ticks by the second; a longer wait shows the words and the local time it ends (app.js does both).
- `user_name(?array $user): string` — The name shown for a member: their display name when display names are on and they set one, else the username. Profile addresses, sign-in and @mentions always use the username. null (a deleted account) gives "deleted".
- `user_link(?array $user, string $class = 'user-link'): string`
- `icon_any(string $name): string` — A built-in icon by name, or an uploaded image (a path under uploads/, e.g. site/cat_3.png) as an icon-sized <img>; '' when neither.
- `category_icon(array $cat): string` — The category's icon when the admin picked one or uploaded an image in Admin → Categories, else ''.
- `category_badge(?array $cat, bool $link = true): string`
- `tag_badge(array $tag): string`
- `pagination(array $p, callable $url_fn): string` — Pagination links. $url_fn(int $page): string
- `list_more_html(string $pagination): string` — Under a topic list whose site loads more while scrolling (setting list_paging = scroll): the Load more button that assets/app.js presses by itself near the end of the page. It follows the rel="next" link of $pagination, which stays under it (updated as pages come in), so crawlers, visitors without JS and anyone who wants to jump still have pages.
- `member_action_html(string $id, array $a, string $class): string` — One member.actions button: a link, or with 'post' a form that POSTs to its url with the CSRF token and back (the page it is on). Keys: label, url, icon, count, title, post; $class carries the place's look (and is-done when the item says done).
- `tabs(array $items, string $class = 'tabs'): string`
- `form_row(string $label, string $field, string $help = ''): string`
- `input(string $name, string $value = '', array $attr = []): string`
- `textarea(string $name, string $value = '', array $attr = []): string`
- `select(string $name, array $options, string $value = '', array $attr = []): string`
- `checkbox(string $name, bool $checked, string $label): string`
- `action_form(string $url, string $button_html, array $hidden = [], string $class = '', string $confirm = ''): string` — A tiny POST form with one button (for actions like like/delete/pin).
- `editor(string $name, string $value = '', string $placeholder = '', array $opts = []): string` — $opts: scope (draft key such as "topic-new", "reply-12", "post-34"), ctx (passed to editor.options hook).
- `user_menu_items(array $me): array` — The account list (region header.user_menu), once per request: the header's account menu shows all of it, the member card the "you" items as a grid of shortcuts. Items: label, url, icon, count, group ("you" by default, or "site"), weight, and card => false to keep an item out of the card's grid (Profile: the card's own avatar and name already open it).
- `quick_prefs_html(): array` — Theme and language as two small round buttons plus the row of languages the second one opens: on phones they leave the header for the account menu (members) or the top of the drawer (visitors). Same controls as the header's: data-toggle="theme" and the POST /language form. Returns [buttons, languages row] ('' for the row when the site has one language).
- `header_right_items(?array $me, int $unread, array $user_menu): array`

## core/router.php

- `routes(?array $add = null): array`
- `router_add(string $pattern, string $handler): void`
- `routes_core(): array`
- `router_rewrite_check(): never`
- `base_path(): string` — Directory the app is served from, "" when at the web root, "/forum" when in a subfolder.
- `base_url(): string`
- `rewrite_enabled(): bool`
- `rewrite_proven(): bool` — Whether this very request proves that clean URLs work: it arrived at a path of its own instead of through index.php. Reading the request is the one check no probe can get wrong (a server may answer a request to its own loopback address from another site, and then the probe 404s while every visitor's link is fine).
- `url(string $path = '/', array $params = []): string` — Build an in-app URL. url('/t/hello-1', ['page' => 2])
- `absolute_url(string $path = '/', array $params = []): string`
- `current_path(): string` — The request path relative to the app, e.g. "/t/hello-1".
- `current_url(): string`
- `is_active_path(string $path): bool`
- `dispatch(): void` — Match the current request and call the handler.
- `router_csrf_exempt_add(array $paths): void` — Routes that authenticate without a browser session (API tokens) and therefore skip the CSRF check. Core has none; plugins declare them in the manifest: 'csrf_exempt' => ['/api/market/publish'].
- `router_csrf_exempt(string $path, array $add = []): bool`
- `routes_final(): array` — The routes with plugins' takeovers applied: the filter router.routes (path => handler) lets a plugin serve a core address, such as a portal at /. The admin, sign-in, settings, setup and API addresses always keep the core's handler. Once per request.
- `routes_taken_over(): array` — Core addresses a plugin serves instead of the core: path => handler (Admin → Plugins lists them).
- `route_match(string $path): array`
- `topic_url(array $topic, int $page = 1, int $post_id = 0): string`
- `topic_unread_url(array $topic): string` — Where a list sends a member for a topic they have partly read: the first post they have not read (topic_unread()).
- `category_url(array $category): string`
- `tag_url(array|string $tag): string`
- `admin_url(string $page = '', array $params = []): string`

## core/schema.php

- `schema_tables(): array`
- `schema_indexes(): array`
- `schema_install(): void` — Create or upgrade all core tables. Safe to run repeatedly.
- `schema_seed(string $admin_name, string $admin_email, string $admin_password): int` — Default groups, categories and the welcome topic. Runs once at install.

## core/search.php

- `search_backend(): string`
- `search_index_install(): void` — Create the SQLite FTS5 table when the extension is available. Called from schema_install().
- `search_index_post(int $post_id, int $topic_id, string $title, string $body): void`
- `search_delete_post(int $post_id): void`
- `search_delete_topic(int $topic_id): void`
- `search_update_title(int $topic_id, string $title): void` — Retitle every indexed post of a topic.
- `search_query(string $q, int $page = 1, int $per_page = 20): array` — Returns ['total' => n, 'rows' => [['post_id'=>..,'topic_id'=>..], ...]]. $filters: ['topic_id' => int, 'user_id' => int] (user filter joins fb_posts).
- `search_rebuild(): int` — Rebuild the whole index from fb_posts. Returns number of posts indexed.

## core/security.php

- `csp_nonce(): string` — Per-request nonce for inline scripts (layout, plugins via script_tag()).
- `csp_policy(): array` — CSP directives as name => sources. Plugins add their CDNs through the `security.csp` filter.
- `security_headers(): void` — Send the security headers once per request (called by dispatch() before any output).
- `script_tag(string $inline = '', string $src = '', array $attr = []): string` — A <script> tag that passes the CSP: inline code or an external src. Plugins use this instead of writing the tag by hand.
- `csp_report_handle(): never` — POST /csp-report (no CSRF: sent by the browser). Appends one line per report to data/csp-report.log, capped at 512 KB.
- `csp_report_file(): string`
- `csp_reports(int $n = 20): array` — The last $n CSP reports, newest first.
- `cloudflare_ranges(): array` — Cloudflare's published ranges (setting trusted_proxies = "cloudflare").
- `trusted_proxy_ranges(): array` — Ranges from the trusted_proxies setting: "cloudflare" and/or IPs and CIDRs separated by commas or spaces.
- `ip_in_cidr(string $ip, string $cidr): bool` — Whether an IPv4/IPv6 address lies in a CIDR range (a bare address means /32 or /128).
- `ip_in_ranges(string $ip, array $ranges): bool`
- `client_ip_resolve(array $server): string` — The visitor's address: REMOTE_ADDR, or the forwarded address when the request came through a trusted proxy.
- `admin_log(string $action, string $target = '', string $detail = ''): void` — Record an admin action (who, real IP, what, on which object). Fires admin.action for plugins.
- `admin_log_recent(int $n = 20): array` — Last $n admin actions with the acting users loaded.
- `sudo_ttl(): int` — How long a password confirmation lasts, in seconds (Settings → Security, minutes; 0 = confirmation switched off).
- `sudo_signature(array $user, int $exp): string`
- `sudo_ok(): bool` — Whether the current admin confirmed the password within the confirmation window (always true when the window is 0 = off).
- `sudo_grant(array $user): void` — Remember a successful password confirmation for sudo_ttl() seconds.
- `need_sudo(): void` — Require a fresh password confirmation for high-risk admin pages: on GET the admin is sent to the confirm page and comes back afterwards; a POST without confirmation is refused so a riding script cannot change anything.

## core/theme.php

- `plugin_is_theme(?array $manifest): bool`
- `themes(): array` — Installed themes keyed by id: the manifest snapshot taken when the folder was scanned, plus 'enabled'. Never runs theme code.
- `theme_enabled_id(): string` — The theme that is switched on for everyone ('' = the core look).
- `theme_active(): string` — The theme this request renders with: the one an admin is previewing, else the enabled one.
- `theme_preview_id(): string` — The installed theme the signed-in admin is previewing ('' when none).
- `theme_manifest(string $id): ?array` — A theme's manifest: the running one for the enabled theme, read as text (never executed) for any other.
- `theme_screenshot(string $id, array $m = []): string` — A theme's preview image inside its folder: manifest 'screenshot', else screenshot.png|jpg|webp; '' when there is none.
- `theme_token_name(string $name): string` — "brand" or "--brand" → "--brand"; '' when the name is not a plain CSS variable name.
- `theme_token_value(string $value): string` — A token value that is safe to print inside <style>; '' when it could close the rule, load something or break out of the tag.
- `theme_tokens(string $id, ?array $m = null): array` — The variables a theme sets, grouped by colour mode: ['all' => [--name => value], 'light' => [...], 'dark' => [...]]. Sources: manifest 'tokens', then settings that carry 'token' (saved value, else the default; 'unit' is appended to numbers, 'values' maps a saved value to CSS, 'mode' picks light or dark). A --brand without its shades gets them derived.
- `theme_tokens_css(array $tokens): string` — CSS for grouped tokens. Light and dark follow the page's data-theme, and "auto" follows the visitor's system.
- `theme_head(): string` — <style> with the active theme's tokens, printed right after app.css (layout.php).
- `theme_css_source(string $id, bool $run_code): string` — A theme's CSS: files from 'assets' => ['css' => [...]], and function sources only when $run_code (the enabled theme).
- `theme_assets_build(): void` — Write data/cache/theme.css for the enabled theme (called with the plugin bundle).
- `theme_assets_tag(): string` — The theme stylesheet, after the plugin bundle. A previewed theme that is not enabled gets its CSS files inline instead.
- `theme_view_file(string $view): string` — The theme's file for a template, or '' to use app/views. Admin pages always use the core templates, so a broken theme cannot lock anyone out.
- `theme_view_failed(string $view, string $file, Throwable $e): void` — A theme template threw: log it, remember it for Admin → Themes (once per message), and let view() render the core template.
- `theme_view_errors(string $id): array` — Errors a theme's templates raised: [view => message]. An error disappears once its template file changes (fixed or updated).
- `theme_core_view_hash(string $view): string` — First 12 hex digits of sha1 over a core template (line endings normalised): the stamp a theme's copy records.
- `theme_view_stamp(string $file): array` — ['view' => name, 'hash' => 12 hex] from the "flatbb-view: name@hash" stamp at the top of a theme template; [] when absent.
- `theme_views(string $id): array` — The templates a theme replaces and how each compares with the core template it was copied from: view => ok | outdated (the core template changed since) | unstamped (no stamp line) | unknown (no such core template).
- `theme_preview_bar(): string` — The bar on top of every page while an admin previews a theme: whose look it is, Activate, Exit. page() inserts it after <body>.
- `theme_preview_inject(string $html): string`

## core/theme_dev.php

- `theme_known_tokens(): array` — Every CSS variable assets/app.css defines or reads (component tokens exist only as var() fallbacks): the names a theme can set.
- `theme_check(string $id, array $m): array` — The rules a theme passes on top of plugin_check(): [errors, warnings]. $m is the loaded manifest.
- `theme_override(string $id, string $view, string $mode = ''): string` — Copy app/views/<view>.php into plugins/<id>/views/ with a stamp line naming the core template and its hash. $mode: '' (refuse to overwrite), 'force' (copy again, the old copy is kept as <view>.php.bak), 'stamp' (keep the copy, record today's core hash after you brought the core changes over). Returns the file path.
- `theme_scaffold(string $id, string $name = ''): array` — Create plugins/<id>/ with a working starter theme (tokens, a colour setting, an empty stylesheet, a README). Returns the files written.

## core/upgrade.php

- `upgrade_endpoint(): string`
- `upgrade_check(bool $force = false): array` — ['version','url','download','sha256'] or ['error' => ...]. Cached for 6 hours unless $force.
- `upgrade_available(): ?array`
- `upgrade_http_get(string $url, int $max_bytes, ?string &$error = null): ?string`
- `upgrade_paths(): array` — Files and directories replaced by an upgrade (relative to ROOT).
- `upgrade_apply(?string $zip_file = null, ?callable $log = null): string` — Download and apply the latest release (or a given zip file). Returns the new version string. Throws RuntimeException with a user-readable message; the site is only modified after the package was verified.
- `upgrade_keep_language_packs(string $old_dir, string $new_dir): array` — lang/ is replaced as a whole: put back the packs the release does not ship (a site's own translation, lang/bg.php). A pack the release does ship is the release's. Returns the file names put back.
- `upgrade_restore(array $done, string $backup, string $root = ROOT): void` — Put back what an interrupted upgrade replaced: every path in $done from the backup (a path that did not exist before goes).
- `upgrade_copy(string $from, string $to): void`
- `upgrade_rmdir(string $dir): void`
- `upgrade_unlink(string $file): void` — Remove a file. On Windows the running script stays open (index.php under php-cgi, flatbb on the command line): deleting it only marks it, its name stays taken until the process ends and the new file cannot be written. Moving it aside first frees the name at once; the moved copy goes when the process ends (or with the next cache clear).
- `upgrade_build_release(): array` — Maintainers: build dist/flatbb-<version>.zip from this checkout and print its sha256.

## core/upload.php

- `upload_url(string $path): string` — The address of an uploaded file. The filter upload.url (ctx: path) lets a plugin serve files from a CDN or an object store it copies them to (upload.after_save). It runs for every avatar and attachment on a page: no database access.
- `upload_mime(string $file): string` — Sniffed mime type. fileinfo is optional: without it, image files are identified by getimagesize() and everything else stays ''.
- `upload_files_list(string $key): array` — $_FILES[<key>] as a list of single-file arrays (a multi-file field is nested by property); files with upload errors are skipped.
- `upload_allowed_types(): array`
- `upload_image_exts(): array`
- `upload_handle(): never` — POST /upload — returns JSON {ok,url,markdown,id,name,is_image}.
- `upload_store(array $user, string $tmp, string $original): array` — Validate, move and register an uploaded file. Throws RuntimeException with a user message.
- `upload_shrink_image(string $file, string $ext, int $max_side): void` — Downscale large images in place (keeps GIFs untouched). Silently skips when GD is missing.
- `avatar_store(int $uid, string $tmp): string` — Square avatar: uploads/avatars/<uid>.jpg. Returns the relative path stored on the user.
- `attachments_link_to_post(int $post_id, int $user_id, string $body): void` — Link freshly uploaded attachments to a post (by their ids embedded in the markdown urls).
- `upload_site_image(string $key, array $file, array $exts, int $max_bytes = 2097152): string` — Store a site asset (logo, favicon) uploaded from the admin: uploads/site/<key>.<ext>. Returns the relative path for setting(); throws RuntimeException with a user message.
- `svg_sanitize(string $svg): ?string` — An uploaded SVG made safe to serve from this site: parsed as XML with no DOCTYPE, no entities and no network, then rebuilt from an allowlist: drawing elements (shapes, paths, groups, gradients, masks, text) with their presentation attributes. Scripts, styles, event attributes, foreign content and links that leave the file (href or url() to anything but #id) are dropped. Returns the cleaned SVG, or null when the file is not an SVG drawing.
- `upload_protect_dirs(): void` — Drop protection files into uploads/ and data/ when they are missing: PHP-FPM/CGI reads .user.ini (engine off), Apache reads .htaccess. nginx needs the rules from nginx.conf.example; the Guard plugin's check-up tells whether they work.

## core/verify.php

- `register_verify_on(): bool`
- `email_code_hash(string $email, string $code): string`
- `email_code_issue(string $email, string $ip, ?string &$error = null): string` — Create a code for an address (replacing an older one) and return it in clear, or '' with $error set when the address is throttled. mail_send() is left to email_code_send() so tests can check codes without a mail server.
- `email_code_send(string $email, string $ip): string` — Issue and mail a code. Returns '' on success or the message to show.
- `email_code_check(string $email, string $code): bool` — Check a code for an address; a correct code is consumed. Five wrong attempts void the code.
- `email_mask(string $email): string` — "n•••@qq.com": enough to recognise one's own address in a mail or a notice, not enough to read it off.
- `email_restore_link(int $uid, string $old, string $new): string` — The link mailed to the old address after an email change: for a week it puts that address back and signs the account out everywhere, so a change the owner did not make can be undone from the inbox that still belongs to them. It is signed with the site secret and bound to the user, both addresses and the expiry; once the address changes again it stops working.
- `email_restore_sig(int $uid, string $old, string $new, int $exp): string`
- `email_restore_user(int $uid, string $old, int $exp, string $sig): ?array` — The user a restore link is good for right now, or null (bad signature, expired, or the address changed since).
- `mail_transport_label(): string` — Which plugin delivers mail (mail.send hook), or PHP's mail() when none does. Shown next to the setting.
- `email_code_cleanup(): int` — Cron: codes older than a day.

## app/account.php

- `account_login(): never`
- `account_register(): never`
- `account_forgot(): never` — GET|POST /forgot — email a password reset link (never reveals whether the address exists).
- `account_reset(string $token): never` — GET|POST /reset/{token}
- `account_lang(): never` — POST /language (code, back): the header language switcher. Sets the cookie and the member's preference, then returns to the page.
- `account_logout(): never`
- `login_throttle_file(): string` — Simple per-IP throttle stored in a cache file (no DB writes on failed logins).
- `login_throttle_ok(): bool`
- `login_throttle_hit(): void`

## app/admin.php

- `admin_index(string $page = 'dashboard'): never` — GET|POST /admin[/{page}]
- `admin_ext(string $plugin, string $page): never` — GET|POST /admin/ext/{plugin}/{page} — plugin admin pages
- `admin_security_checks(bool $force = false): array` — Deployment self-check shown on the dashboard: private directories must not be reachable over HTTP. Results are cached for an hour (setting security_check); POST action=recheck refreshes.
- `admin_settings_fields(): array`
- `admin_logo_block(): string` — Settings → General → Logo: the three styles as cards, the icon and the logo uploads (a dark version too), the phone option, and a preview of the header (desktop, dark, phone) that follows the choices before they are saved (assets/app.js).

## app/admin_ai.php

- `admin_ai_providers(): array`
- `admin_ai_settings(array $sections): never` — GET and POST of the AI tab. $sections: the settings tabs, for the tab bar.
- `admin_ai_status(?array $conn, array $s): string` — The flag in a connection card's head: not set up, resting after an error, last answer, last error.
- `admin_ai_swap(int $a, int $b): void` — Move a connection up: swap every field of $a and $b, and what the site remembers about them.
- `admin_ai_test(array $numbers): never` — Ask each connection one tiny question and flash one line for all of them; the cards show the details.

## app/admin_content.php

- `admin_category_icon_unlink(int $id): void` — Remove a category's uploaded icon file(s) (uploads/site/cat_<id>.*).

## app/admin_import.php

- `import_job_view(array $job): array` — The job without the importer's state and secret, for the page script.
- `import_phase_text(array $p, bool $current, bool $past): string` — One phase's numbers: "31,200 / 74,590", "waiting", "done".
- `import_job_html(array $job, string $back): string` — Progress bars, status and the job's buttons; a running job carries data-import, which the page script keeps going.
- `import_steps_html(array $labels, int $current): string` — Step pills for an importer page: import_steps_html(['Connect', 'Check', 'Import', 'Done'], 2) (English labels, 1-based current step).
- `import_stats_html(array $stats): string` — Numbers found in the source forum: import_stats_html(['Members' => 3412, 'Topics' => 9806]).
- `import_duration(float $s): string`
- `import_report_download(array $job): never` — The report as a text file: what went where, and every note.

## app/admin_menus.php

- `admin_menu_items_of(string $region): array` — One menu's items with where each came from: id => item + ['_src' => 'core' | 'custom' | plugin id, '_edited' => bool, '_default' => the item before the admin's edits].

## app/admin_points.php


## app/admin_system.php

- `admin_layout_items(string $region): array` — Every item of a list region as the site shows it: the core's own entries plus what plugins add, in the effective order (hidden ones included). Each plugin callback runs on its own with ctx ['layout' => true]; a plugin that adds nothing here (its card only shows on some pages, or to members) is listed under its own id and name, so the admin can still place it. Plain HTML items take their label from the card heading.
- `admin_tools_security_html(): string` — Tools page: the last admin actions and the last CSP reports (the security plugin shows the full history).

## app/admin_theme.php

- `admin_themes_post(string $back): never` — Every POST of the page (the preview bar's buttons post here too).
- `admin_theme_zip_manifest(string $file): ?array` — The manifest of plugin.php inside an uploaded zip, read as text (the file is not executed); null when there is none.
- `admin_theme_card_default(bool $on): string` — The built-in look, always first.
- `admin_theme_card(string $id, array $t, bool $on): string`
- `admin_theme_swatch(array $tokens): string` — A small drawing of a page in a theme's colours, for themes without a screenshot. [] = the default look.

## app/admin_ui.php

- `admin_menu_items(string $active): array`
- `admin_page(string $title, string $body, string $active = '', array $opts = []): never`
- `admin_drawer_links(array $d): string`
- `admin_drawer_html(array $d): string`
- `admin_drawer_link(string $url, string $label, string $class = 'btn btn-sm', string $icon = ''): string`
- `admin_row_menu(array $items): string` — "…" menu: pass a list of link/form HTML; forms should be action_form() with a plain button.
- `admin_switch(string $url, array $hidden, bool $on, string $title = ''): string` — Toggle switch posted via AJAX: admin_switch(admin_url('plugins'), ['action' => 'disable', 'id' => $id], true)
- `admin_table(array $head, array $rows, string $empty = '', array $sort = []): string` — $sort = ['region' => …, 'url' => …] makes the rows draggable: the row keys are the item ids, a drop posts action item_order with the ids in their new order to url (app.js), like the chips of Admin → Widgets.
- `admin_form_actions(string $primary, string $cancel_url = ''): string` — Sticky save bar used at the end of drawer forms.

## app/api.php

- `api_dispatch(string $action): never` — Small JSON API used by app.js, plus sitemap and RSS. POST /api/preview body -> {html} GET /api/users?q= -> [{username, avatar}] for @mention autocomplete GET /api/unread -> {count} any /api/<action> -> hook api.<action> for plugins (return array to respond)
- `feed_topics(array $rows): array` — Topics that may appear in a public feed: plugins drop what a visitor without an account may not read (hook feed.topics).
- `seo_sitemap(): never`
- `seo_manifest(): never` — Web app manifest: "Add to home screen" uses the site name and icon instead of the page title.
- `seo_rss(): never`

## app/category.php

- `categories(): array` — All categories keyed by id, ordered by sort. Cached per request.
- `category_by_id(int $id): ?array`
- `category_by_slug(string $slug): ?array`
- `category_can_view(array $c): bool`
- `category_can_post(array $c): bool`
- `category_visible_ids(): ?array` — Ids of categories the current user may see; null means "no restriction".
- `category_tree(): array` — Visible categories grouped as parent => children for menus.
- `category_refresh_stats(int $id): void`
- `category_index(): never` — GET /categories
- `category_view(string $slug): never` — GET /c/{slug} — the category's latest topics (children included).
- `category_top(string $slug, string $period = 'week'): never` — GET /c/{slug}/top[/{period}]
- `category_unread(string $slug): never` — GET /c/{slug}/unread
- `category_list(string $slug, string $mode, string $period = 'week'): never` — One category page: Latest / Top / Unread limited to the category and its children.

## app/home.php

- `home_index(): never` — GET /
- `home_latest(): never`
- `home_top(string $period = 'week'): never`
- `home_unread(): never`
- `top_periods(): array` — Top periods: key => seconds back (0 = all time).
- `top_scope(string $period): array` — Where clause and params for a Top period (404 on an unknown one); '' means no time limit.
- `top_order(): string`
- `top_sub_tabs(string $period, string $base): string` — Period tabs under Top; $base is '/top' or '/c/<slug>/top'.
- `unread_scope(array $me): array` — Unread for a member: [join, where, params] — topics with posts they have not seen, last 30 days.
- `topic_list_page(): array`
- `topic_list_fetch(string $where, array $params, string $order, array $p, bool $pinned_first = false, string $join = ''): array` — Load a page of topics with users, categories and tags attached (batched, no N+1). $where uses alias t for fb_topics. Returns ['topics'=>[], 'pagination'=>[]].
- `topic_list_new_config(array $topics, array $category_ids = []): array` — What the "See N new or updated topics" pill of a Latest list asks about: the newest activity the page shows, and its categories.
- `topic_list_new_count(int $since, array $category_ids = []): int` — Topics that became new or got a reply after $since, with the visibility of the lists themselves (hidden categories, the topic_list.query filter), so a count never reveals a topic the reader could not open. A member's own posts do not count.
- `topic_list_attach(array $rows): array` — Attach user, last_user, category, tags and (for members) unread flag to topic rows.
- `topic_list_page_render(string $title, array $list, string $active, callable $url_fn, string $sub_tabs = '', array $opts = []): never` — Render a standard list page with tabs.
- `category_bar(string $active): string` — Category bar at the top of list pages (page option 'top'; region main.categories, list): All + top-level categories, the current one highlighted (a child page highlights its parent). Setting category_bar: mobile (default; the left column is hidden there) | always | off. Icons appear only for categories that have one.
- `new_topic_url(): string` — The New Topic address: inside a category list (set by list_tabs()) the composer opens on that category.
- `list_tabs(string $active, array $extra = [], ?array $category = null): string` — Tabs above topic lists (region main.tabs). Inside a category the tabs and New Topic stay in that category.

## app/moderation.php

- `user_moderate_refusal(array $target, array $by): string` — Why $by may not act on $target ('' = may).
- `user_ban(int $uid): void` — Suspend a member and sign them out on every device.
- `user_unban(int $uid): void`
- `user_remove_content(int $uid, int $by): array` — Remove everything a member wrote (soft: moderators can restore a post) and reject what waits in the review queue. Counts of the topics, categories and tags touched are brought up to date. Returns ['topics' => n, 'replies' => n].
- `user_delete(int $uid): array` — Delete a member and everything they wrote, for good: their topics (with the replies in them), their replies elsewhere, likes, bookmarks, read marks, notifications to and from them, uploaded files, points and review items. Counts of what stays are brought up to date. Plugins clear their own rows on user.after_delete (ctx: user_id, topic_ids, post_ids). Returns ['topics' => n, 'replies' => n, 'files' => n].
- `upload_file_delete(string $path): bool` — Remove a file under uploads/ (a stored path, with or without its ?v= stamp). Never leaves uploads/.
- `user_moderate(array $uids, string $action, array $by): array` — Apply an action (ban | remove | delete | unban) to members, as $by. Members $by may not act on are skipped. Returns ['done' => [uid, …], 'skipped' => [uid => reason], 'topics' => n, 'replies' => n].
- `user_moderate_message(array $r, string $action): string` — The message after an action.
- `user_moderate_page(string $name): never` — GET|POST /u/{name}/moderate — what the member did, and the actions. Moderators and administrators.

## app/notification.php

- `notify_wanted(int $to, string $kind): bool` — Whether the member wants this kind of notification (Settings → Preferences): the preference notify_<kind> set to 0 turns a kind off, so a plugin's own kind gets a switch by saving that key (user.prefs_save). Kinds nobody switched off are sent.
- `notify_pref_on(string $prefs, string $kind): bool`
- `notify(int $to, int $from, string $kind, string $content = '', int $topic_id = 0, int $post_id = 0): bool` — Create a notification. Returns false when suppressed (self, duplicate, preference, hook veto).
- `notify_many(array $to, int $from, string $kind, string $content = '', int $topic_id = 0, int $post_id = 0): int` — Notify many members of the same thing (a new topic for everyone who follows its author) in a few queries: recipients are read 100 at a time (active accounts that did not switch the kind off), written with one INSERT per 100 and their unread counts raised with one UPDATE each. The filter notification.before_create runs for every recipient (no DB there); notification.after_create_many fires once. Returns how many were notified.
- `notify_reply(array $topic, int $post_id, int $from, int $reply_to_id): void`
- `notify_mentions(int $topic_id, int $post_id, string $body, int $from): void`
- `notify_like(array $post, int $from): void`
- `notifications_unread(): int`
- `notification_index(): never` — GET /notifications
- `notification_read(): never` — POST /notifications/read — mark all read (AJAX)

## app/points.php

- `points_wallet(): never` — GET /points[?kind=in|out&page=n]

## app/review.php

- `review_hold_reason(string $kind, int $user_id, int $category_id, string $text): string` — Why a new topic ('topic') or reply ('reply') by $user_id waits for review, '' when it goes out at once. The reason is an English source text, shown through t(). Plugins add their own with the filter review.hold (value: the reason so far; ctx: kind, user, category, text); administrators and moderators are never held.
- `review_trusted(array $user): bool` — A member a moderator approved "and trusted": their posts no longer wait for the new-member rule.
- `review_hold(string $kind, int $topic_id, int $post_id, int $user_id, string $reason): void` — Put a held topic or reply in the queue and tell the moderators (one unread notice each, however many wait).
- `review_staff_ids(): array` — Active administrators and moderators.
- `review_count(): int` — How many items wait (one query a request; the account menu shows it to moderators). Items whose post was deleted meanwhile do not count.
- `review_approve(array $r, int $by): bool` — Approve a waiting item: the content goes public and sets off what a new post sets off; its author is told.
- `review_reject(array $r, int $by, string $note): bool` — Reject a waiting item: the content is deleted (moderators can still see it) and its author gets the note.
- `review_trust(array $r, int $by): int` — Approve every waiting item of this member and trust them: the new-member rule no longer holds their posts.
- `review_ban(array $r, int $by): bool` — A spammer: suspend the account, delete everything they wrote (their waiting items are rejected), and bring the counts of the topics and categories they wrote in up to date.
- `review_page(): never` — GET /review[?tab=waiting|approved|rejected]: the queue, for moderators.
- `review_act(): never` — POST /review/act: id, do = approve | trust | reject | ban, note (reject). Answers JSON to the page's script, else goes back.

## app/search.php

- `search_page(): never` — GET /search?q=
- `search_snippet(string $body, string $q): string` — Excerpt around the first matching term, with <mark> highlights (safe HTML).

## app/setup.php

- `setup_index(): never` — Web installer (/setup). One form: database, site name, administrator. Writes data/config.php only after the schema and seed data were created successfully.
- `setup_checks(): array`
- `setup_rewrite_works(): bool` — Probe /__rewrite_check on this host; true when clean URLs work.

## app/tag.php

- `tag_normalize(string $name): string`
- `tags_parse(string $csv): array` — Parse "a, b, c" into normalized unique names (max 5).
- `tags_for_topics(array $topic_ids): array` — Tags for many topics: [topic_id => [tag rows]]. One query.
- `topic_set_tags(int $topic_id, array $names): void` — Replace a topic's tags. Creates missing tags and keeps counts accurate.
- `tag_index(): never` — GET /tags
- `tag_view(string $name): never` — GET /tag/{name}

## app/topic.php

- `topic_by_id(int $id): ?array`
- `post_by_id(int $id): ?array`
- `topic_title_valid(string $title): bool`
- `post_body_valid(string $body): bool`
- `topic_create(int $category_id, int $user_id, string $title, string $body, array $tags = [], bool $pinned = false): int` — Create a topic with its first post. Returns topic id. When review_hold_reason() holds it (app/review.php) both rows are stored with is_deleted = 2 and wait in the review queue: counts, search, @mentions, topic.after_save, points and link previews happen when a moderator approves it (topic_go_public()), so nobody hears of a post nobody may see yet.
- `topic_go_public(int $tid): void` — What a topic sets off when it becomes public: at once for most, on approval for one that waited in the review queue.
- `post_create(array $topic, int $user_id, string $body, int $reply_to = 0): int` — Add a reply. Returns post id. A held reply (review queue) waits with is_deleted = 2; post_go_public() runs on approval.
- `post_go_public(int $pid): void` — What a reply sets off when it becomes public: the topic's last reply and counts, search, notifications, post.after_save, points, link previews.
- `topic_stats_refresh(int $tid): void`
- `topic_mark_read(int $tid, int $last_post_id): void`
- `can_manage_topic(array $topic): bool`
- `content_visible(array $row): bool` — Whether the current user may see this topic or post row by its is_deleted state: shown (0) to everyone, deleted (1) to moderators, waiting for review (2) to moderators and its author.
- `post_topic_visible(array $post): ?array` — The topic of a post when the current user may see it (content_visible(), category viewable), else null.
- `can_edit_post(array $post): bool`
- `topic_view(string $id): never` — GET /t/{slug}-{id}
- `topic_unread(string $id): never` — GET /t/{id}/unread: the page of the first post the member has not read, scrolled to the "New replies" line above it (the way Discourse and Flarum do). A topic opened only on its first page stayed unread for good when the new replies were on a later page. Nothing read yet, all read, a visitor or no access: the topic itself.
- `topic_new_from(array $posts, int $prev_read): int` — The first post on this page the member had not read before this visit (the "New replies" line goes above it); 0 when none or never opened.
- `topic_first_unread(array $topic, int $uid): ?array` — ['post_id' => …, 'page' => …] of the first post $uid has not read in $topic, or null (never opened, nothing unread, a visitor).
- `topic_reply_denied(array $topic): string` — Why the visitor may not reply to this topic, '' when they may (hook topic.can_reply); moderators and the author always pass.
- `topic_access_denied(array $topic): string` — Why the visitor may not read this topic, '' when they may. Plugins answer with the reason to show (hook topic.access); moderators and the author always pass. The same reason keeps a topic out of search and the feeds.
- `topic_no_access(array $topic, ?array $cat, string $why): never` — The page a refused visitor gets: the title stays readable, the body is the reason.
- `topic_count_view(int $id): void` — One view per visitor and topic: a cookie remembers the topics this browser opened in the last day, so a reload or a second page of the same topic adds nothing. Members and guests count the same way.
- `posts_attach(array $posts, array $topic): array` — Attach user rows, liked flag and reply-to previews to posts (batched).
- `topic_related(array $topic): array`
- `topic_new(): never` — GET|POST /new-topic
- `topic_category_fallback(string $title, string $body, array $me): ?array` — A new topic without a category: a plugin may pick one (filter topic.category_missing, ctx title, body, user; return a category id, e.g. what an AI chose), then the default category when the site does not require one. null: ask the writer.
- `topic_category_auto(): bool` — Whether a plugin will pick the category of a topic sent without one (filter topic.category_auto: true when it can right now).
- `topic_category_optional(): bool` — Whether the composer may leave the category empty: not required, or a plugin that picks one is ready.
- `topic_reply(string $id): never` — POST /t/{id}/reply
- `topic_edit(string $id): never` — GET|POST /t/{id}/edit — title, category, tags and first post.
- `post_update(array $post, string $body, int $editor_id): void`
- `topic_action(string $id): never` — POST /t/{id}/action action=pin|unpin|lock|unlock|delete|restore|move (pin on a pinned topic moves it to the top of the pinned ones)
- `topic_bookmark(string $id): never` — POST /t/{id}/bookmark — toggle
- `post_permalink(string $id): never` — GET /post/{id} — jump to the page containing the post
- `post_edit(string $id): never` — GET|POST /post/{id}/edit
- `post_like(string $id): never` — POST /post/{id}/like — toggle
- `post_delete(string $id): never` — POST /post/{id}/delete — soft delete (mods can restore with action=restore)
- `post_raw(string $id): never` — GET /post/{id}/raw — markdown source for quoting

## app/user.php

- `user_rename_allowed(): bool` — Whether members may change their own username (Admin -> Settings -> Registration).
- `user_rename_next(array $user): int` — Unix time from which this member may rename again (0 when never renamed).
- `display_name_next(array $user): int` — When a member may change their display name again (0 = now); administrators are not limited.
- `user_profile(string $name, string $tab = 'topics'): never` — GET /u/{name}[/{tab}] tabs: topics, replies, bookmarks (own only)
- `user_settings(string $tab = 'profile'): never` — GET|POST /settings[/{tab}] tabs: profile, avatar, password, preferences
- `user_pref(string $key, mixed $default = null): mixed`
- `settings_email_post(array $me, string $back): never` — POST /settings/email. action=verify: the code mailed to the current address marks it verified. Otherwise a change, the way the big services do it: the current password, then the new address and the code sent to it (when the site verifies addresses); nothing changes until both check out. The old address is told, with a link that undoes the change for a week.
- `user_email_restore(): never` — GET|POST /email/restore — the link from the "your email was changed" mail: confirm, then the old address is back and every session ends.




==============================================================================
Themes and layout (docs/THEME.md)
==============================================================================

A **theme** changes how a FlatBB forum looks: colours, shapes, type, component styles and, when needed, the HTML of single templates. Features (pages, tables, settings that do things) belong in [plugins](PLUGIN.md); a theme only changes the look.

A theme **is a plugin** whose manifest says `'type' => 'theme'`. It is packaged, uploaded, checked and published exactly like a plugin, and the marketplace lists it under Themes. The differences:

- One theme is on at a time. Switching a theme on switches the previous one off; no theme means the built-in look of `assets/app.css`.
- It is managed under **Admin → Appearance → Themes** (cards with a screenshot, Activate, Preview, Customize, Upload theme), not under Plugins.
- An admin can **Preview** an installed theme: only their own browser shows it, with a bar on top of every page (Activate / Exit preview).

## 1. The three layers

Use the lightest layer that does the job.

| Layer | Where | What for |
| --- | --- | --- |
| **Tokens** | manifest `tokens` | Colours, radius, fonts, spacing of components. Most themes need nothing else. |
| **Stylesheet** | manifest `assets.css` (a file in the theme) | Restyle components beyond what tokens reach. Loads after `app.css` and after every plugin's CSS. |
| **Templates** | `views/<name>.php` in the theme | Change the HTML of one core template (`app/views/<name>.php`). Last resort: templates must follow core changes. |

## 2. Quick start

```bash
php flatbb theme:new midnight --name="Midnight"   # plugins/midnight/: plugin.php, assets/theme.css, README.md
php flatbb theme:check midnight                    # tokens, templates, screenshot, CSS hints, security rules
```

Then Admin → Appearance → Themes → **Scan plugins folder**, **Preview**, **Activate**. Without a command line, create the same files by hand (below), zip the folder and use **Upload theme**.

```
plugins/midnight/
  plugin.php          manifest (data only) with 'type' => 'theme'
  screenshot.png      1200×800, shown on the theme card and required by the marketplace
  assets/theme.css    optional component styles
  views/              optional template copies (php flatbb theme:override midnight topic_rows)
  README.md           what the theme looks like, its settings
```

## 3. Manifest

```php
<?php
if (!defined('FLATBB')) exit;

return [
    'id' => 'midnight',
    'type' => 'theme',
    'name' => 'Midnight',
    'version' => '1.0.0',
    'description' => 'Deep blue surfaces, violet accent, tighter corners.',
    'author' => 'you',
    'requires' => ['flatbb' => '0.1.89'],
    'screenshot' => 'screenshot.png',            // optional: screenshot.png|jpg|webp is found without it
    'tokens' => [
        'all'   => ['--radius' => '8px', '--radius-sm' => '6px', '--card-shadow' => 'none'],
        'light' => ['--bg' => '#f4f5fb', '--panel' => '#ffffff', '--line' => '#e3e5f0'],
        'dark'  => ['--bg' => '#0d1020', '--panel' => '#151a2e', '--line' => '#262c45'],
    ],
    'settings' => [
        'accent' => ['type' => 'color', 'label' => 'Accent colour', 'default' => '#7c5cff', 'token' => '--brand'],
        'corners' => ['type' => 'number', 'label' => 'Corner radius (px)', 'default' => 8, 'min' => 0, 'max' => 20, 'token' => '--radius', 'unit' => 'px'],
        'flat' => ['type' => 'checkbox', 'label' => 'Flat cards (no shadow)', 'default' => 1, 'token' => '--card-shadow', 'values' => ['1' => 'none', '0' => '']],
    ],
    'assets' => ['css' => ['assets/theme.css']],
];
```

| Key | Meaning |
| --- | --- |
| `type` | `'theme'`. Anything else is a plugin. |
| `tokens` | Groups `all` (every colour mode), `light`, `dark`, each `'--variable' => 'value'`. Light and dark follow the page's colour mode, and "follow system" follows the visitor's system. |
| `settings` | The usual settings schema ([PLUGIN.md §5](PLUGIN.md)), shown under **Customize** on the active theme. A setting with `token` sets that variable from the saved value: `unit` is appended to a number, `values` maps the saved value to CSS (a value mapped to `''` sets nothing), `mode` (`light`/`dark`) limits it to one colour mode. |
| `assets.css` | Stylesheet files inside the theme (a function name also works; it runs only while the theme is on). `assets.js` is bundled like a plugin's. |
| `screenshot` | Image inside the theme folder. |

The manifest is data (no calls, no variables), as for plugins. The rules of [PLUGIN.md §4](PLUGIN.md) apply: own CSS classes and functions carry the theme id prefix.

Token values are printed inside `<style>`, so a value may not contain `; { } < > \`, a comment, `url(` or `@import` (backgrounds with images go in the stylesheet). When a theme sets `--brand` as `#rrggbb` without `--brand-hover`/`--brand-soft`, both shades are derived. A brand colour the admin set in Settings → General still wins over the theme.

## 4. Tokens

Global variables, defined in `:root` of `assets/app.css` (light) and overridden for dark mode:

| Group | Variables |
| --- | --- |
| Brand | `--brand` (Settings → General → Brand color), `--brand-hover`, `--brand-soft`, `--brand-text` |
| Surfaces | `--bg`, `--panel`, `--panel-2`, `--line`, `--line-soft`, `--backdrop`, `--shadow`, `--shadow-md` |
| Text | `--text`, `--text-muted`, `--text-subtle`, `--text-disabled` |
| Status | `--success`, `--danger`, `--warning`, `--info` and `*-soft` |
| Shape | `--radius`, `--radius-sm`, `--radius-pill` |
| Type | `--font`, `--mono`, `--font-size-xs` 11px, `-sm` 12px, `-md` 14px, `-lg` 16px, `-xl` 18px, `-2xl` 22px, `-3xl` 26px |
| Layout | `--topbar-h`, `--left-w`, `--right-w`, `--gap`, `--container`, `--page-x` (side margin of the page: 20px, 16px on tablets, 12px on phones; an edge-to-edge strip uses `margin-inline: calc(-1 * var(--page-x))`) |

Component tokens are **not defined** anywhere: core rules read them with a fallback, `var(--card-bg, var(--panel))`, so an unset token means the default look. Set them to restyle a component without CSS:

| Token | Default | Used by |
| --- | --- | --- |
| `--line-height` | `1.55` | body text |
| `--heading-weight` | `650` | h1–h4 |
| `--topbar-bg`, `--topbar-line` | `--panel`, `--line` | top bar |
| `--card-bg`, `--card-line`, `--card-radius`, `--card-shadow` | `--panel`, `--line`, `--radius`, `--shadow` | `.card`, `.list-card` (no shadow), `.post` |
| `--card-pad`, `--card-head-pad` | `16px`, `12px 16px` | card body and head |
| `--post-pad` | `16px` | posts on a topic page |
| `--btn-pad`, `--btn-radius`, `--btn-weight` | `7px 14px`, `--radius-sm`, `550` | `.btn` |
| `--input-pad`, `--input-radius`, `--input-bg` | `9px 11px`, `--radius-sm`, `--panel` | inputs, selects, textareas |
| `--tab-pad`, `--tab-radius`, `--tab-active-bg`, `--tab-active-text` | `6px 12px`, `--radius-sm`, `--brand-soft`, `--brand` | `.tab` |
| `--side-link-radius`, `--side-link-active-bg`, `--side-link-active-text` | `--radius-sm`, `--brand-soft`, `--brand` | left navigation |
| `--row-pad`, `--row-line` | `11px 16px`, `--line-soft` | topic list rows (phones keep their own padding) |
| `--avatar-radius` | `50%` | avatars (`8px` gives rounded squares) |
| `--flag-radius` | `4px` | `.flag` labels |
| `--footer-bg`, `--footer-line` | `--panel`, `--line` | footer |

Variables of your own start with the theme id: `--midnight-glow`.

## 5. Stylesheet

- Loads last (after `app.css`, the admin brand colour and all plugin CSS), so equal selectors win without `!important`. Never use `!important`.
- Use variables for every colour, so light and dark mode both work. A colour that must differ per mode is a token in `light`/`dark`.
- Use logical properties (`margin-inline-start`, `inset-inline-end`, `text-align: start`), never `left`/`right`: right-to-left languages mirror the layout.
- Restyle core classes freely (list below); new classes carry the theme prefix (`.midnight-hero`).
- The stylesheet also applies on admin pages, so keep forms and tables readable.

## 6. Templates

```bash
php flatbb theme:override midnight topic_rows          # copies app/views/topic_rows.php to plugins/midnight/views/ with a stamp
php flatbb theme:override midnight topic_rows --stamp  # after merging core changes into your copy: record the new core version
php flatbb theme:override midnight topic_rows --force  # copy the core template again (your copy is kept as topic_rows.php.bak)
```

- `views/<name>.php` renders instead of `app/views/<name>.php`, with the same variables (each core template lists them in its first comment).
- The first line is the stamp `<?php /* flatbb-view: topic_rows@3f2a9c1b2d4e … */ ?>`: the core template and its hash when it was copied. When the core template changes, `theme:check` and the theme card list the template as older than the core. The theme keeps working meanwhile.
- Keep every `region()`, `slot()` and `hook()` call of the core template: they are where plugins put their buttons, badges and cards.
- The template rules of the core apply: `<?= ?>` starts with `h()`, `t()`, `raw()` or a safe helper; no database queries in templates; text through `t()`.
- **Admin pages always use the core templates**, so a broken theme can never lock an admin out.
- A template that throws (an undefined variable counts) is replaced by the core template for that render, and the error is shown on the theme card.
- Override the smallest template that works: `card_stats` or `topic_rows`, not `layout`.

Templates: `layout` (page shell), `topic_list`, `topic_rows`, `categories`, `category_head`, `topic`, `post`, `post_rows`, `topic_form`, `post_form`, `editor`, `sidebar_left`, `sidebar_right`, `sidebar_topic`, `card_user`, `card_stats`, `card_newest`, `card_related`, `card_author`, `profile`, `settings`, `notifications`, `search`, `tags`, `login`, `register`, `forgot`, `reset`, `error`, `setup`.

## 7. Check, package, publish

`php flatbb theme:check <id>` runs every `plugin:check` rule plus:

- errors: unknown token groups, invalid variable names or values, a template that replaces no core template, a missing screenshot file named in the manifest;
- warnings: a variable that is not a core variable and not prefixed, templates without a stamp or older than the core, no screenshot, many hard-coded colours, physical `left`/`right`, `!important`, routes or admin pages in a theme.

Package and publish like a plugin: `php flatbb plugin:package <id>`, `php flatbb plugin:publish <id> --images=screenshot.png`, or upload the zip at https://www.flatbb.com/market/publish. The marketplace requires at least one screenshot for a theme.

## 8. Prompt to give an AI

```
Read CLAUDE.md and docs/THEME.md in this repository. Create the FlatBB theme plugins/<id>/ (run php flatbb theme:new <id> first) that looks like: <colours, mood, density, corners, fonts, references>.
Use tokens first, assets/theme.css only for what tokens cannot do, and template overrides (php flatbb theme:override) only if the HTML must change.
Support light and dark mode. When done, run php flatbb theme:check <id> and fix every error and warning.
```

Without a checkout: "Read https://www.flatbb.com/dev/plugins.md (the Themes section), then write plugins/<id>/plugin.php with 'type' => 'theme' that …". Zip the folder and upload it under Admin → Appearance → Themes.

## Layout (desktop ≥ 1200px)

```
┌──────────────────────────── header ─────────────────────────────┐
│ [☰] [logo] header.left  header.nav … header.right.before_search  │
│      [search] header.right.after_search [theme] [+] [🔔] [avatar]│
├───────────────┬────────────────────────────────┬────────────────┤
│ sidebar.left  │ main.before                    │ sidebar.right  │
│  .top         │ main.tabs / main.toolbar       │  .top          │
│  .nav (list)  │ topic_list.item.* (per row)    │  .cards (list) │
│  categories   │ topic_list.after               │  .bottom       │
│  tags         │ main.after                     │                │
│  .bottom      │                                │                │
├───────────────┴────────────────────────────────┴────────────────┤
│ footer.left            footer.links (list)          footer.right │
└─────────────────────────────────────────────────────────────────┘
```

Topic page: `topic.header`, `topic.actions` (list), `post.before` / `post.content_after` / `post.actions` (list) / `post.after` per post, `topic.replies_after`, `composer.toolbar` (list), `composer.extra`; right column becomes `topic.sidebar.top` / `.cards` / `.bottom`.

Breakpoints: < 1200px the right column moves under the main column; < 992px the left column becomes an off-canvas drawer (☰); < 640px compact rows and icon-only actions.

Every position is listed in `regions_known()` and in Admin → Widgets, where custom HTML blocks can be inserted without code.

## Right-to-left

`<html dir="rtl">` is set when the active language pack declares `'__dir' => 'rtl'` (Persian, Arabic, Hebrew). `assets/app.css` uses logical properties, so the whole layout mirrors by itself; a few `[dir="rtl"]` rules flip the phone drawer and directional icons. Themes follow the same rule.

## Reusable classes

`.card .card-head .card-body`, `.list-card .list-head`, `.btn .btn-primary .btn-ghost .btn-danger .btn-sm .btn-lg .btn-block`, `.icon-btn`, `.tabs .tab`, `.flag .flag-danger .flag-success`, `.badge`, `.tag-badge`, `.cat-badge`, `.muted .small .mono .hidden`, `.form-row .form-grid .form-help .check`, `.flash .flash-error .flash-success`, `.empty`, `.table-wrap table.admin`, `.post-content` (markdown typography), `.topic-rows .topic-row`, `.pagination`, `.dropdown .dropdown-menu`, `.topbar`, `.side-link`, `.footer`.

## Icons

`icon('name')` renders inline SVG (24×24, `currentColor`, stroke 2). Add icons from a plugin with the `icon.paths` hook: `$paths['myid-star'] = '<path d="…"/>';`.

## Logo and colours

Admin → Settings: site name, logo and favicon (uploaded images; the default mark is `logo_mark()` in core/render.php and `assets/favicon.svg`, both drawn from the same SVG, the inline one follows `--brand`), brand colour, default colour mode (auto/light/dark), footer text, extra `<head>`/`</body>` HTML.
