# nano **Repository Path**: wfdaj/nano ## Basic Information - **Project Name**: nano - **Description**: No description available - **Primary Language**: PHP - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-26 - **Last Updated**: 2026-09-30 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Nano PHP >= 8.2 的微型论坛框架。运行时只依赖 Composer 的自动加载器,开发期只依赖 PHPUnit——没有容器、没有 ORM、没有模板语言,全部由 `core/` 里的几十个小类组成。 - **`core/`** — 框架本身(`Nano\`):路由、钩子、HTTP、PDO 封装、插件加载、纯 PHP 模板引擎。 - **`app/`** — 论坛应用(`App\`):控制器、服务、实体、钩子名枚举、路由中间件、权限契约、模板。 - **`plugins/`** — 可插拔扩展(`Plugins\\`),示例插件 `BadWords`(敏感词过滤)与两个**互斥**的编辑器 `TinyMde`(Markdown)、`Trix`(富文本)开箱可用。 想了解每个部件的设计取舍,见 [CODEBUDDY.md](CODEBUDDY.md)(面向维护者/AI 的架构说明);本文件只讲怎么把它跑起来。 ## 环境要求 - PHP >= 8.2,启用 `pdo_sqlite`(默认)或 `pdo_mysql` - Composer 2 ## 快速开始 ```bash composer install # 安装 PHPUnit 并生成 vendor/autoload.php # 初始化数据库(Bootstrap 不会自动建表) sqlite3 storage/nano.sqlite < db/sqlite.sql # MySQL:先建库,再导入 mysql -u root nano < db/mysql.sql # 插件先行:任何 boot 都会校验「缓存覆盖 enabled」,所以这两条要排在其它脚本之前 php bin/plugins rebuild # 按「生效名单」生成插件缓存(运行期状态优先,缺失则回退 config 声明) php bin/plugins migrate # 插件数据迁移(如 BadWords 把 1.x 留在库里的词表搬进 storage 下的 JSON 文件);安装 / 升级后跑一次 php bin/seed # 演示数据:默认话题(公告 / 技术 / 闲聊)+ 10 个测试用户(demo01..demo10 / demo1234)+ 20 条帖子,可重复执行 php -S localhost:8000 -t public # 启动开发服务器 ``` 打开 即可。前台提供注册 / 登录 / 退出 / **找回密码**(无 SMTP 也能用:邮件写进 `storage/log/nano.log`,重置链接从日志里取;链接绝不回显到页面)、首页帖子流与话题列表(**置顶帖优先**,其余按发布时间倒序)、**用户主页**(`/user/{id}`:帖子与最近回复,作者名可点),时间显示为「3 天前」这类相对时间;登录后可在**账号设置**(`/settings`)里**上传头像**(存到 `storage/uploads/avatars/<年>/` 并在 `attachments` 表登记,取图统一走 `/attachment/{id}`,因此换头像即换来新的附件 id、可以长缓存)与**修改密码**(须先验当前密码;会话被劫持时不能直接换掉主人的密码),可发帖、可编辑或删除**自己的**帖子(仅作者本人,删帖会一并清掉该帖回复),可在帖子详情页回帖(回复按时间正序分页展示)。**话题按需创建、可以为空**:发帖 / 编辑时在「话题」输入框里填一个名字即可,不存在会自动建(也可以写成 `#话题#`;输入框留空时,正文里的第一个 `#话题#` 会被当作话题);留空且正文没有 `#话题#` 的帖子就是「无话题」,正常显示、不报错。话题以 id 定位,点话题名进 `/topic/{id}` 看该话题下的帖子。未登录访问受保护页面会先跳登录页并带上 `next`,**登录成功后自动回到原来的页面**。发帖 / 编辑 / 回帖的正文框自带**默认编辑器**(应用内置,无需插件):工具条可**上传图片**(上传后往正文插入 `![说明](/attachment/<附件 id>)`,渲染时变成正文里的 ``;图片存在 `storage/uploads/posts/<年>/<月>/` 下并登记进 `attachments` 表,取图统一走 `/attachment/{id}` 路由,**只有本站这种形状的标记会被转成图片**——外链、`data:` 一律留在文本里)与插入 **emoji 表情**(emoji 本身就是普通字符,正文仍是纯文本)。启用 `BadWords` 插件后,**管理员**可从后台分区导航或后台「插件管理」页的「插件功能」入口进入 `/admin/bad-words` 维护敏感词表(未登录跳登录页,已登录非管理员 403)。启用 `TinyMde` 插件后,发帖 / 编辑 / 回帖的正文输入框会换成带工具条的 Markdown 编辑器;正文仍按纯文本保存与展示(论坛不保留 HTML),编辑器改变的是输入体验,不是渲染管线。想换成 `Trix`(basecamp/trix 富文本编辑器,同样按纯文本保存与展示)也可以,但**两个编辑器插件只能启用一个**:它们互相声明了 `conflicts`,先停用 `TinyMde` 再启用 `Trix`(反之亦然),同时启用时启用动作会被拒绝并把原因 flash 出来。 **等级成长与权限**:发帖 / 回帖 / 每日签到等行为获得经验(发帖 +5 每日上限 25、回帖 +1 上限 10、签到 +1),累计经验达到门槛自动升级(L1 新手 0 → L2 见习 100 → L3 正式 300 → …→ L10 传奇 50000);等级解锁权限(默认 L3 起可上传正文图片、L5 起可发站外链接),判定统一走 `PermissionService::can()`,未来 VIP / 版主等并列角色实现 `PermissionRule` 注册进去即可,调用方不用改。登录后进 **我的等级**(`/level`)看进度、今日额度、经验流水与权限一览,并每日签到;`/levels` 是公开的等级与权限总览。经验流水 `exp_logs` 是真相、`users.exp` 是缓存:删帖 / 删回复会按流水扣回(可能掉级),`php bin/level reconcile` 或后台「等级」页可在缓存跑偏时一键对账。 **后台**:注册的**首个用户自动成为管理员**(`users.is_admin`),导航栏会出现「后台」入口。后台按功能分区(页内子导航切换,不再把全部功能堆在一页上):`/admin` 概览(计数与各分区入口)、`/admin/topics` 话题(列表 / 删除;**删话题不删帖**,其下的帖子回到「无话题」)、`/admin/posts` 帖子(置顶 / 锁定 / 设置话题 / 删除)、`/admin/replies` 回复(删除)、`/admin/plugins` 插件。**插件管理页可直接启用 / 停用插件**:按「已安装」列出全部插件(含未启用的)与三态状态(未启用 / 已启用 / 已启用但本次未启动),点一下即把开关写进运行期状态 `storage/plugins.enabled.json`(`config/plugins.php` 保持只读,是初始名单)并重建缓存,**下一次请求生效**;停用只改准入、保留插件数据,而声明了自有表 `schema` 却还没迁移的插件会被提前拦下并提示 `php bin/plugins migrate `(请求路径不执行 DDL)。「声明与生效不一致」的插件会被单独标注(状态文件存在后,往 `config/plugins.php` 的声明里加插件不会自动生效,删掉状态文件即回到声明)。该页同时渲染插件经 `admin.menu` 钩子提供的「插件功能」入口。非管理员访问后台被拒绝:未登录跳转登录页,已登录但非管理员返回 403。 `/admin*` 是**统一管理员区**:全局中间件 `App\Middleware\AdminZone` 在路由匹配之前按路径前缀把守,因此 app 与插件共用同一套规则 —— 插件新挂 `/admin/...` 路由时即使忘了声明 `RequireAdmin` 也进不来(漏写不再等于失守,连不存在的 `/admin/xxx` 也不会先漏 404)。路由上显式声明 `RequireAdmin` 依旧可行,两者叠加是幂等的。 ## 目录约定 ``` nano/ ├── app/ # 论坛应用,按层拆分(Composer 一层一条 PSR-4 前缀) │ ├── Controller/ # App\Controller\ │ ├── Hook/ # App\Hook\ 应用钩子名枚举 AppHook │ ├── Middleware/ # App\Middleware\ 路由中间件(如 RequireLogin 登录守卫) │ ├── Model/ # App\Model\ 实体与 DTO │ ├── Permission/ # App\Permission\ 权限名常量与「并列角色」扩展契约 PermissionRule │ ├── Service/ # App\Service\ 应用服务 │ └── View/ # 纯 PHP 模板(模板根目录,不含类,所以不是命名空间) ├── bin/ │ ├── plugins # 正式 CLI:插件缓存 rebuild / list + 生命周期 migrate / deactivate / uninstall │ ├── level # 等级运维:status(经验对账体检)/ reconcile(按流水重算)/ rules(门槛与规则) │ ├── seed # 演示数据种子(默认话题 + 10 用户 / 20 帖子,幂等) │ ├── seed-demo # 演示数据种子(10 用户 / 20 帖子,幂等) │ └── smoke # 端到端冒烟(真实装配链路) ├── config/ # 全部配置;Bootstrap 只 glob 这一层 ├── core/ # 框架(Nano\);不得 import App\ 或 Plugins\ ├── db/ # sqlite.sql / mysql.sql ├── plugins/ # 可插拔扩展,每个插件自成一个目录 │ ├── BadWords/ # 敏感词过滤(后台页;出厂默认词表 data/words.json,运行期词表在 storage/) │ ├── DebugBar/ # 右下角调试面板(仅 debug + 管理员) │ ├── TinyMde/ # Markdown 编辑器 │ └── Trix/ # 富文本编辑器(与 TinyMde 互斥,只能启用一个) ├── public/index.php # 唯一入口(front controller) ├── tests/ # Core / App / Plugins 与源码分层对称,Hooks 为夹具 └── storage/ # 运行时产物:日志、SQLite 文件、插件缓存、会话文件、上传文件(git 忽略) ``` 两条硬性边界: 1. **`core/` 是框架,不认识应用。** 它不 import `App\`(唯一例外是 `core/Bootstrap.php`——把应用装进框架的装配点),更不 import `Plugins\`。 2. **`app/` 是应用,不认识插件。** 依赖方向只有一条:插件 → 应用 → 框架。所以插件可以直接用 `App\Service\UserService`、`App\Hook\AppHook`、`App\Model\PostDraft`,反之不行。 `app/View/` 只有模板没有类,因此它**没有**对应的 PSR-4 前缀:`composer.json` 里 `App\` 是按层逐条映射的(`App\Controller\`、`App\Service\`、`App\Model\`、`App\Hook\`、`App\Middleware\`、`App\Permission\`)。新增一个 `app//` 时,记得补上映射并 `composer dump-autoload`。 ## 配置 所有配置都在 `config/*.php` 里,返回数组,按文件名分组(`config/database.php` → `database.*`)。敏感信息走项目根的 `.env` 文件(`cp .env.example .env` 起步,`.env` 已被 git 忽略,**不读进程环境变量**)——由 `Nano\Config\Env` 独家解析,`config/*.php` 与 `public/index.php` 共用同一份判定: | 键 | 默认值 | 说明 | | --- | --- | --- | | `ENV` | `prod` | 环境名;取 `dev` / `test` 时默认开启 debug | | `DEBUG` | 未设置 | 设为 `1` 开启 debug(错误页直接显示堆栈),优先级高于 `ENV` | | `DB_DRIVER` | `sqlite` | `sqlite` 或 `mysql` | | `SQLITE_PATH` | `storage/nano.sqlite` | SQLite 文件路径,`:memory:` 可用于临时库 | | `DB_HOST` / `_PORT` / `_NAME` / `_USER` / `_PASS` | `127.0.0.1` / `3306` / `nano` / `root` / 空 | MySQL 连接参数 | | `BASE_URL` | 未设置 | 找回密码邮件里链接的站点前缀(如 `https://forum.example.com`);留空则只输出站内路径 | | `SESSION_PATH` | `storage/sessions` | PHP 会话文件目录(覆盖 `php.ini` 的 `session.save_path`),目录缺失时自动创建 | | `CACHE_DRIVER` / `_PATH` / `_TTL` | `file` / `storage/cache` / `300` | 缓存驱动(`file` / `array` / `null`)、目录与默认存活秒数 | | `UPLOAD_DIR` / `_URL` / `_MAX_SIZE` | `storage/uploads` / `/uploads` / `2097152` | 上传目录、对外 URL 前缀与单文件上限(字节) | | `UPLOAD_AVATAR_DIR` | `avatars` | 头像在 `UPLOAD_DIR` 下的子目录(头像是 `<该目录>/<年>/<随机名>.`) | | `UPLOAD_EDITOR_DIR` | `posts` | 正文图片在 `UPLOAD_DIR` 下的子目录(正文图片是 `<该目录>/<年>/<月>/<随机名>.`,与取图路由的段数一一对应) | | `BAD_WORDS_FILE` | `storage/plugins/BadWords/words.json` | BadWords 插件的词表文件(冒烟脚本改道到临时目录,避免污染真实词表) | | `SEED_FORCE` | 未设置 | 设为 `1` 时允许在 `prod` 环境跑 `php bin/seed` | 路径类的键请写绝对路径(相对路径按 PHP 进程的当前工作目录解析,不一定是项目根);留空或整行注释掉即回落上表的默认值。 `config/plugins.php` 的 `enabled` 数组是插件准入的**声明**(初值):不在里面的插件目录即使存在也不会 boot;写了却不存在则 `rebuild` 直接失败。运行期的开关状态另存于 `storage/plugins.enabled.json`(后台插件管理页或 `php bin/plugins enable|disable` 写它),该文件一旦存在即优先于声明 —— 所以往声明里加插件后若没生效,先看 `php bin/plugins list` 打印的差异(删掉状态文件就回到声明)。`config/security.php` 定义全局响应头,`config/middleware.php` 定义全局中间件(见「全局中间件与请求生命周期」),`config/app.php` 定义时区,并把 `env` / `debug` 交给 `.env`(`ENV` / `DEBUG`)——`debug` 除了驱动插件缓存的自动重建,也会在 boot 成功后让错误页切换成带堆栈的 debug 版(启动期异常只认 `.env` 里的 `DEBUG` / `ENV`)。 ## 错误页 所有错误出口(404 / 405 / 419 / 控制器抛出的 `HttpException` / 未捕获异常的 500)都渲染同一个 HTML 错误页,由 `config/error.php` 配置: ```php return [ 'template' => 'error/page', // 模板标识符同 View:默认在 app/View,@Plugin/ 前缀可指向插件模板 'debug_template' => 'error/debug', // debug 下改用这一张(额外显示异常类、位置、堆栈) 'messages' => [404 => '你要找的页面不存在。', /* ... */], ]; ``` - 文案优先级:控制器/路由给出的 message → `messages[状态码]` → 内置 reason phrase(`Not Found` 等)。 - 模板只拿到 `status` / `title` / `message` / `reason` / `home` / `error`,输出一律经 `e()`;默认模板自包含(不依赖 `layout/header`),因为出错时布局依赖的会话、用户服务或插件模板命名空间可能正不可用。 - 想换成 JSON / 纯文本 / 完全自定义的形状,监听 `error.response` 过滤器(载荷是 `Response`,附加参数 `int $status, string $message, ?\Throwable $error`),返回新的 `Response` 即可;也可实现 `Nano\Contracts\ErrorRenderer` 整体替换渲染器。 - 错误页自身绝不再抛:模板缺失、模板内出错、过滤器返回非 `Response`,都降级为纯文本 `状态码 + 原因`。`boot()` 之前(例如装配失败)完全走这条纯文本路径。 ## 命名路由 路由**注册时带上名字**,之后一律用名字生成 URL——模板、控制器、守卫里不再出现内部路径字面量: ```php // app/Controller/PostController.php:路由表的唯一定义处(register() 只是 #[On] 入口,转调这里) $collector->get('/post/{id}', [self::class, 'show'], 'post.show'); ``` ```php // 控制器 / 中间件:注入 Nano\Routing\UrlGenerator(Bootstrap 已注册为服务) return Response::redirect($this->urls->url('post.show', ['id' => $post->id])); ``` ```php …
``` - `{param}` 占位符按名填充(单段、`rawurlencode`);**未被占位符消耗的键自动变成查询串**,所以 `url('post.create', ['topic' => '技术'])` 得到 `/post/create?topic=%E6%8A%80%E6%9C%AF`。 - 名字不存在、占位符缺参、参数不是 `string|int`,一律抛异常——宁可 500,也不要让页面里出现空链接或错链接。 - 路由名规则 `^[a-z][a-z0-9_.]*$` 且全局唯一;路径改名只需改上文 `routes()` 里那一处,模板、跳转、守卫、分页基路径自动跟随。 - 插件同理:路由名来自插件自己注册的路由,控制器里 `$services->get(Nano\Routing\UrlGenerator::class)->url('bad_words.index')`,模板里 `$view->route('bad_words.add')`。 ## 全局中间件与请求生命周期 **全局中间件**作用于每一个请求(含未命中路由的 404/405),在 `config/middleware.php` 的 `global` 列表里声明一次即可全站生效: ```php return [ 'global' => [ App\Middleware\RequestId::class, // 类名须已注册为服务,框架不自动实例化 ], ]; ``` - 形状与路由中间件完全一致:`__invoke(Request $request, callable $next): Response`;**声明顺序即执行顺序**,最外层先执行,不调用 `$next` 即短路(控制器不会执行)。 - 与路由中间件共用同一套洋葱管线(`Nano\Routing\MiddlewarePipeline`),区别只在声明处:全局一次、路由逐条。缺省自带的 `App\Middleware\RequestId` 给每个请求挂一个 id 并回写 `X-Request-Id`。 - 插件也能注册:`boot()` 里 `$services->get(Nano\Routing\MiddlewareStack::class)->add(...)`;管线在插件 boot 之后装配,所以插件注册的全局中间件同样生效。 - 配置里写错类名(不存在 / 未注册为服务)在启动时立刻失败,不会在请求期间悄悄失效。 **请求生命周期钩子**由 `Nano\Http\Kernel` 固定触发,插件与应用都可监听(见 `Nano\Contracts\Hook\CoreHook`): | 钩子 | 类型 | 载荷 | 时机 | | --- | --- | --- | --- | | `request.received` | filter | `Request` → `Request` | 进入内核、任何路由匹配之前 | | `response.ready` | filter | `Response` → `Response` | 分发完成、全局安全头落锤之前 | ```php #[On(CoreHook::RequestReceived, type: On::FILTER, priority: 10)] public function stamp(Request $request): Request { return $request->withAttributes(['tenant' => 'acme']); } ``` - 钩子只做**观察 / 改写**,无法短路;需要拦截(未登录跳转、维护模式 503 等)请写成全局中间件。 - 监听器返回类型不符时保留原值,避免一个写错的监听器把正常请求变成 500。 - 错误响应不走 `response.ready`——它由 `error.response` 过滤器负责(见「错误页」)。 整条链路(`App::handle()` 委托 `Nano\Http\Kernel::handle()`): ``` request.received → 全局中间件 → 路由匹配 → 路由中间件 → 控制器 → response.ready → 全局安全头 ``` ## 命令行 ```bash php bin/plugins rebuild # 重建 storage/plugins.cache.php(每次改完插件都要跑) php bin/plugins list # 列出名单来源、插件、入口类与它们注册的监听器 php bin/plugins enable # 启用插件:写运行期状态 storage/plugins.enabled.json + 重建缓存(后台同源) php bin/plugins disable # 停用插件:同上,但保留插件数据(要另释放运行期资源才是 deactivate) php bin/plugins migrate [Name...] # 运行插件自有迁移(缺省=全部已启用插件) php bin/plugins deactivate # 停用插件:释放运行期资源,保留数据 php bin/plugins uninstall # 卸载插件:删插件自有表 + 清理 plugin_storage(仍启用时拒绝,需先 disable) php bin/smoke # 端到端冒烟:重建缓存 → 真实启动 → 钩子 → 插件路由 → 插件模板 php bin/level status # 等级体检:缓存经验 / 等级 / 流水合计并排,有对不上的以退出码 1 结束 php bin/level reconcile # 按经验流水重算全站经验与等级(对账修复) php bin/level rules # 打印等级门槛与经验规则(配置体检) ``` `#[On]` 监听器与插件 `boot()` 里注册的服务都写在缓存里,所以**编辑插件后必须 `rebuild`**。debug 模式下(`DEBUG=1` 或 `ENV=dev|test`)缓存缺失或过期会自动重建;生产环境缓存只读,请求期间绝不写盘。 `migrate` / `deactivate` / `uninstall` 是**离线**生命周期操作(走 `Bootstrap::bootForTest()` 装配后按 `plugins//plugin.json` 定位入口,因此目标插件不在 `enabled` 里也能操作):`migrate` 幂等建表(安装/升级后跑一次),`deactivate` 只释放运行期资源、**保留**数据,`uninstall` 才删表并清空该插件的 `plugin_storage` 数据 —— 停用是可逆的,卸载不是。 `uninstall` 在插件**仍被启用**时直接拒绝执行(退出码 1)。删表与白名单不同步时,下一个请求会 boot 成功、查表失败(500)—— 命令既然知道危险,就不该「警告一句然后照删」,这与框架「宁可响亮失败」的取舍一致。停用请用 `php bin/plugins disable `(与后台插件管理页同一个入口):它会把开关写进运行期状态 `storage/plugins.enabled.json` 并**立刻重建缓存** —— 状态是准入真相、缓存是它的编译产物,两者必须同进同退。写入是原子的,「读当前状态 → 写状态 → 重建」整段在文件锁内完成(两个管理员同时开关不会互相覆盖),且**重建失败会把状态回滚**,绝不留下「状态说启用、缓存却没有」这种下一请求必定启动失败的状态。`config/plugins.php` 因此是**只读的声明**:它是初始名单与部署产物,程序不再改写它(代价是状态文件存在后,往声明里加插件不会自动生效 —— `rebuild`/`list` 会打印差异,删掉状态文件即回到声明)。 **「启用却忘了 `migrate`」同样会在启动时被拦下。** 插件在清单里声明 `schema` 后,`migrate` 成功会把版本记进 `plugin_storage`(键 `__schema_version`),boot 读到版本对不上就抛出并点名 `php bin/plugins migrate ` —— 而不是等发帖 / 回帖撞上缺表才 500。校验**只读不写**(运行路径绝不执行 DDL),并且生命周期命令自身以跳过校验的方式启动,否则 `migrate` 会被自己的校验挡在门外。全新数据库(`plugin_storage` 都还没建)只记日志不拦启动:那是「数据库未初始化」,不该误报成「插件没迁移」。**启用前**也会读同一份记录:`php bin/plugins enable ` 与后台插件管理页在没迁移时直接拒绝并说明原因,因此不会出现「启用成功、下一请求启动失败」。 **缓存必须覆盖 `enabled`,否则启动即失败。** 忘了 `rebuild` 时,缓存缺失/过期会让插件全部消失 —— 这种「站点照常 200、扩展静默没了」最难排查,所以 boot 会直接抛出并点名缺失的插件、提示运行 `rebuild`,而不是降级成「零插件」。因此任何走 `Bootstrap` 的脚本(`bin/seed`、`bin/smoke`…)都要求缓存已就绪:**先 `rebuild`,再跑其它命令**。debug 下缓存缺失/过期会自动重建;若重建后仍覆盖不了 `enabled`(例如 `enabled` 里写了未安装的插件),同样响亮失败,不会拿旧缓存硬撑。 ## 测试 ```bash vendor/bin/phpunit # 全量 vendor/bin/phpunit tests/Plugins/BadWordsFilterTest.php # 单个文件 vendor/bin/phpunit --filter testLongestWordWins # 单个用例 ``` 测试目录与源码对称:`tests/Core/` 测框架,`tests/App/` 测应用,`tests/Plugins/` 测插件;`tests/Hooks/` 是被各测试引用的监听器夹具,不是测试本身。PHPUnit 配置为严格模式(警告、risky、意外输出都会失败)。 ## 写一个插件 插件是应用级扩展:一个目录 + 一份清单,命名空间固定为 `Plugins\\`,目录名就是插件名。 ``` plugins/Hello/ ├── plugin.json # 清单:身份 / 展示 / 依赖 / 运行环境约束 ├── HelloPlugin.php # 入口:实现 PluginBootInterface ├── View/ # 模板(模板标识符前缀 @Hello/) └── public/ # 静态资源(经 /plugin-assets/Hello/… 发布,见「自带样式与脚本」) ``` `plugin.json` 字段: | 字段 | 必填 | 说明 | | --- | --- | --- | | `name` | ✔ | 必须等于目录名,`^[A-Z][A-Za-z0-9]{0,63}$` | | `version` | ✔ | 裸版本号(`1.2.0`),供别的插件用 `requires` 约束 | | `entry` | ✔ | 入口 FQCN,必须落在 `Plugins\\` 下 | | `description` / `author` | | 展示用;出现在 `php bin/plugins list` 与后台插件管理页 | | `requires` | | 依赖插件:`{"Other": ">=1.0"}` 或 `["Other"]` | | `conflicts` | | **点名**互斥插件:`["Rival"]` 或 `{"Rival": "都改写 post.before_save"}`(只在两者都被启用时报错) | | `provides` | | 声明的**能力**(如 `["editor.wysiwyg"]`):同一能力只能有一个提供者,见下 | | `schema` | | 插件自有表的版本(正整数)。声明后启动时会校验迁移是否已跑过;入口类**必须**实现 `PluginMigrateInterface`(否则版本永远登记不上:`rebuild`、启动期与启用准入三处都会拒绝,并告诉你实现 `migrate()` 或删掉这条声明) | | `nano` | | 框架版本约束,比对 `Nano\Version::VERSION`(如 `">=1.0"`) | | `php` | | PHP 版本约束,比对 `PHP_VERSION` | | `extensions` | | 必须已加载的 PHP 扩展,如 `["pdo_sqlite"]` | `conflicts` 与 `provides` 是两种互补的互斥表达:前者是「我知道你叫 Rival,我俩不能同时开」,后者是「我提供 `editor.wysiwyg` 这个能力」—— 两个编辑器插件各自声明同一能力即可表达「同类只启用一个」,不必硬编码对方的名字,也不会因为只写了一边而静默叠加。同一能力被两个已启用插件提供时,`rebuild` 直接失败并列出双方。 仓库里的两个编辑器插件走的是 `conflicts` 这条路:`plugins/Trix/plugin.json` 点名 `TinyMde`、`plugins/TinyMde/plugin.json` 点名 `Trix`(**双向**声明,谁先启用都会被拦下)。于是 `plugins/Trix` 与 `plugins/TinyMde` 同时出现在启用名单里时 `rebuild` 失败并带上原因,后台「插件管理」页点启用会把原因 flash 出来 —— 想换编辑器就先停用另一个,而不是让两个插件去抢同一个正文控件。 约束统一用 `version_compare()` 认识的写法(`>=`、`<=`、`!=`、`>`、`<`、`=`),刻意不支持只在 Composer 里有意义的 `^` / `~`。`php` / `extensions` / `nano` 在 `php bin/plugins rebuild` 时校验:**这台机器跑不了的插件不会进缓存**,而不是等运行期才炸。 ```php namespace Plugins\Hello; use Nano\Hook\HookManager; use Nano\Container\ServiceRegistry; use Nano\Plugin\PluginBootInterface; use Nano\View\View; final class HelloPlugin implements PluginBootInterface { public function boot(HookManager $hooks, ServiceRegistry $services): void { // 注册自己的服务 $services->set(Greeter::class, new Greeter()); // 注册模板命名空间:之后可用 "@Hello/index" 引用 $services->get(View::class)->addTemplateNamespace('Hello', __DIR__ . '/View'); } } ``` 监听器用属性声明在**任意插件类**上(不必是入口类),构建期扫描时会被发现: ```php use Nano\Hook\On; use App\Hook\AppHook; final class Greeter { #[On(AppHook::PostBeforeSave, priority: 20)] public function rewrite(PostDraft $draft): PostDraft { /* ... */ } } ``` 监听器类**不要在自己的 `boot()` 里 `new`**:它们由 `ListenerFactory` 依据 `#[On]` 懒构造,每个类至多一个实例;`boot()` 只注册监听器**依赖的服务**(把共享状态放进服务,如 `WordList`)。工厂在构造前会先查注册表 —— 若你确实以类名为 id 注册了监听器实例,它会复用那一个而不是再 new 一份;手动 `new` 再 `set()` 则会与工厂的实例分叉,状态一旦进监听器就会各写各的。 `#[On]` 的类型与钩子名**自动对账**:钩子用枚举(`CoreHook` / `AppHook`)时类型由枚举派生,可以省略 `type:`,写了也必须一致;钩子是裸字符串时必须显式写 `type:`(否则报错)。这拦住了最阴的一类 bug —— 过滤器忘写 `type: On::FILTER` 会落进 action 桶、静默永不触发。 插件 `boot()` 的登记是**事务性**的:入口先 `set()` / `add()` 若干东西再抛错时,本次 boot 对服务注册表、钩子列表、全局中间件栈的改动会整体回滚。生产环境据此**干净地**跳过失败插件(日志 + `skipped()` 列表),而不是留下一批「解析得到、却无人负责」的幽灵服务。 三种钩子点(枚举值即钩子名,`#[On]` 也接受任意字符串)分别见 `Nano\Contracts\Hook\CoreHook` 与 `App\Hook\AppHook`: - `action`(广播,如 `CoreHook::RouteRegister`、`AppHook::PostAfterSave`) - `filter`(管道,如 `AppHook::PostBeforeSave` 的 `PostDraft`→`PostDraft`、`AppHook::NavMenu`/`AppHook::AdminMenu` 的 `Menu`→`Menu`、`CoreHook::ViewRenderHeader` 的 `HtmlString`→`HtmlString`、`CoreHook::ErrorResponse` 的 `Response`→`Response`、`AppHook::PostRenderContent` 的纯文本) - 插件路由不在类里硬写:用 `#[On(CoreHook::RouteRegister, priority: 50)]` 注册,优先级低于应用内置路由(100)保证内置路由先匹配。注册时给它起个路由名(如 `bad_words.add`),之后跳转与模板链接都用这个名字生成(见「命名路由」)。 最后把它加进 `config/plugins.php` 的 `enabled`,再跑 `php bin/plugins rebuild`。此后启停用 `php bin/plugins enable|disable ` 或后台「插件管理」页即可:开关写在 `storage/plugins.enabled.json`,不动这份声明(换句话说,声明是「初始名单」,运行期状态存在后以状态为准)。 ### 给界面加入口(导航与后台菜单) 插件要往页面里插入口,**不需要**改 `app/` 的任何模板,也不需要拿整段 HTML 做字符串手术:应用在渲染处触发菜单钩子,插件只 `add()` 结构化菜单项。 | 钩子 | 载荷 | 触发处 | | --- | --- | --- | | `AppHook::SiteMenu`(`site.menu`) | `Nano\View\Menu` | 前台页头导航 `layout/header`(站内功能入口,访客也会看到) | | `AppHook::NavMenu`(`nav.menu`) | `Nano\View\Menu` | 后台分区导航 `admin/_nav`(不放页头:后台功能挂页头等于每个前台页面都挂着后台入口) | | `AppHook::AdminMenu`(`admin.menu`) | `Nano\View\Menu` | 后台插件管理页(`/admin/plugins`)的「插件功能」区 | ```php use App\Hook\AppHook; use App\Model\User; use Nano\Hook\On; use Nano\View\Menu; use Nano\View\MenuItem; use Nano\View\ViewContext; final class MenuContributor { // 第二个参数是视图上下文:当前路由名 + 当前用户,由应用在触发钩子时统一注入。 #[On(AppHook::NavMenu, type: On::FILTER, priority: 30)] public function nav(Menu $menu, ViewContext $context): Menu { // 菜单项用命名路由(不是路径):路由改名时链接自动跟随,渲染时经 e() 转义。 $isAdmin = $context->user instanceof User && $context->user->isAdmin(); return $isAdmin ? $menu->add(new MenuItem('敏感词', 'bad_words.index')) : $menu; } } ``` `Menu` / `MenuItem` / `ViewContext` 都是框架原语(`core/View/`),因此应用模板与插件共享同一批类型,**`app/` 不必认识任何 `Plugins\` 类** —— 依赖方向仍然只有一条(插件 → 应用 → 框架)。参考实现:`plugins/BadWords/Hook/MenuContributor.php`。 - **上下文**(`ViewContext`):`route`(当前命中的路由名,由 `Nano\Routing\CurrentRoute` 记录)与 `user`(当前登录用户,通常是 `App\Model\User`)。有了它,「按权限显示」「只在这一页出现」由应用统一提供,插件不必自己注入 `UserService` 去查库 —— 页头在每个页面都要渲染,而 `UserService::currentUser()` 本身也按会话做了请求级记忆化。类型上 `user` 是中性 `?object`:`core/` 不能依赖 `App\`,插件拿到后自行 `instanceof App\Model\User` 判断即可(插件依赖应用是允许的方向)。既有监听器写成 `nav(Menu $menu): Menu` 也照常工作。 - **前台 / 后台分开**:`site.menu` 渲染在页头(每个前台页面、含访客),放的是站内功能入口;`nav.menu` 渲染在后台分区栏,`admin.menu` 渲染在后台「插件功能」区,放的是后台功能。放错地方不会报错,但会把后台链接摆到每个前台页面上 —— 三个钩子的触发处见上表。 - **层级**:`MenuItem` 接受 `children`(`list`),应用布局渲染为父链接 + 次级链接。 - **高亮**:用 `$context->isRoute('post.show')` 或 `$view->currentRouteName()` 判断当前页 —— 用路由名而不是路径,路由改名时判断自动跟随。 `CoreHook::ViewRenderHeader`(`view.render.header`,`HtmlString → HtmlString`)仍然保留,作为确实需要整段改写的逃生口;「加一个入口」这类需求应当走菜单钩子,而不是字符串手术。 ### 自带样式与脚本(静态资源通道) 插件要带 CSS/JS,把文件放进 `plugins//public/`,用 `$view->pluginAsset('Name', '样式或脚本.css')` 生成 URL:`/plugin-assets/BadWords/bad-words.css`。由全局中间件 `App\Middleware\PluginAssets` 在路由之前接住并短路(路由的 `{param}` 不支持 catch-all 段)。为什么必须走这条通道:应用 CSP 是 `script-src 'self'`,**内联脚本会被浏览器拦掉** —— 只有同源的真实文件才能被引用(`style-src` 也已有 `'unsafe-inline'`,但样式同样建议外置)。三条边界:插件必须**已启用**、文件必须位于该插件的 `public/` 内(realpath 牢笼)、扩展名在白名单里(css/js/图片/字体;`.php`、`.sql` 之类一律取不到)。 缓存分两层:`Cache-Control` 随 debug 切换(开发 `no-cache`、生产 `public, max-age=86400`),并带 `ETag` / `Last-Modified` + 条件请求 → `304`。没有第二层时,浏览器缓存过期后只能整份重下、服务端每次也得把文件读进内存 —— 对动辄几百 KB 的编辑器 JS,代价全压在重复访问上。 #### 把资源挂到页面上(资源钩子) 只生成 URL 还不够:在插件自己的模板里写 `` 只能覆盖插件自己的页面,进不了发帖页这类应用页面;过去只剩 `view.render.header` 这条对整段 HTML 做字符串手术的路。现在应用布局在 `` 处触发资源钩子,插件 `add()` 一条结构化声明即可: ```php use App\Hook\AppHook; use Nano\Hook\On; use Nano\View\Asset; use Nano\View\AssetCollection; use Nano\View\View; use Nano\View\ViewContext; final class EditorAssets { public function __construct(private readonly View $view) {} #[On(AppHook::HeadAssets, type: On::FILTER, priority: 20)] public function head(AssetCollection $assets, ViewContext $context): AssetCollection { // 只在实际需要的页面挂编辑器:上下文里的路由名让这件事不必猜路径。 if (!$context->isRoute('post.create', 'post.edit', 'reply.create')) { return $assets; } return $assets ->add(Asset::style($this->view->pluginAsset('Hello', 'editor.css'))) ->add(Asset::script($this->view->pluginAsset('Hello', 'editor.js'))); // 默认 defer } } ``` `Asset::style()` / `Asset::script()` 的 URL 必须是**站内路径**(单个 `/` 开头),渲染时再转义一次;输出 `` / `