未启用插件的设置与前台入口在后台隐藏,直接访问设置页只显示启用提示,设置保存接口同样拒绝未启用插件。停用保留配置和数据;重新启用后继续使用原配置。声明式设置与运行时注册设置执行相同的启用要求。
纸鱼博客插件用于增加站点功能、监听系统事件、修改系统数据或输出前台内容。插件放在站点根目录的 plugins/ 下,每个插件使用一个独立目录。
插件目录
插件目录名只能使用小写字母、数字、下划线和连字符,长度为 1 到 50 个字符。
最小插件结构:
plugins/example/
├── plugin.json
└── extension.php # 唯一扩展入口plugin.json 描述插件,入口文件返回插件的实现。
plugin.json
name 必须与目录名一致,title 和 version 为必填项:
{
"name": "example",
"title": "示例插件",
"version": "1.0.0",
"apiVersion": 2,
"description": "一个 纸鱼博客插件",
"author": "你的名字",
"authorUrl": "https://example.com",
"homepage": "https://example.com/example-plugin",
"settings": [],
"injects": []
}apiVersion 当前支持 1 和 2,新插件建议使用 2。v2 插件只有在 injects 中声明过的前台注入点才会输出内容。
插件可以用 requires 声明本地扩展依赖,例如 ["plugin:notify_hub >=1.0.0"]。启用和每次启动都会检查依赖插件是否存在、已启用且版本满足约束;商店目录中的 requiresPhp 只用于安装前 PHP 版本检查,写进 plugin.json 不会生效。
依赖检查会递归遍历依赖扩展的依赖,并拒绝循环链。API v2 插件应在 plugin.json 声明路由元数据,在 extension.php 用同名 provider() 绑定处理器;ctx->route() 保留给 API v1 旧插件兼容。
可选清单字段
字段 | 说明 |
|---|---|
| 分类标识。 |
| 仅 |
| 声明式后台菜单,见下文“后台菜单和路由” |
| 为核心实体声明扩展字段,见“声明核心实体字段” |
| 声明为云端存储后端, |
| 插件前台入口路径,显示在插件列表与设置页,例如 |
|
|
| 应用商店展示用元数据; |
插件目录名与 name 必须一致,只允许小写字母、数字、下划线和连字符,长度 1–50。缺少 extension.php 的目录会被判定为不可用插件。
编辑器插件
编辑器插件通过 admin_editors 过滤器向核心提供编辑器实现,核心只接受满足全部条件的提供者:
category必须是editor,且当前编辑类型在editorScopes中;提供者必须返回
assets/下的脚本;资源路径不能越出插件目录,扩展名只允许js、css、woff、woff2、svg、png;format只能是markdown或html,它决定保存时写入的content_format。
{
"category": "editor",
"editorScopes": ["post", "page", "micro"]
}$ctx->filter('admin_editors', static function (array $editors): array {
$editors['markdown'] = [
'plugin' => 'editor_markdown',
'label' => 'Markdown 增强',
'format' => 'markdown',
'scripts' => ['assets/editor.js'],
'styles' => ['assets/editor.css'],
];
return $editors;
});启用的编辑器插件自动接管对应编辑页,编辑页不再让用户逐篇切换。启用新的编辑器插件会自动停用旧的同类插件;新插件启用失败时,核心会恢复原来的激活列表。编辑器静态资源通过 /editor-assets/{plugin}/{path} 输出,只对已启用插件开放。
插件入口
插件只有一个扩展入口:extension.php。
extension.php(推荐)
返回一个接收扩展上下文的闭包,可直接注册钩子、过滤器、路由、注入和生命周期:
<?php
declare(strict_types=1);
use Pafish\Services\ExtensionContext;
return static function (ExtensionContext $ctx): void {
$ctx->on('after_login', static function (array $user) use ($ctx): void {
$ctx->log('用户登录:' . ($user['username'] ?? ''));
});
$ctx->route('GET', '/hello', static fn (): string => '<h1>你好</h1>');
$ctx->lifecycle('onUninstall', static function () use ($ctx): void {
$ctx->setData([]);
});
};业务实现可以拆分到同目录的其他 PHP 文件,再由 extension.php 显式 require 并注册 provider;这些文件不会被核心自动发现。
插件每次请求启动时都会检查声明式 schema 的版本和指纹;只有发生变化时才执行幂等增量同步,因此不会每次请求查询 INFORMATION_SCHEMA。已启用插件升级后无需重新停用/启用即可补齐新增表、列和索引。停用插件后,系统会自动移除该插件注册的钩子。
插件上下文 ctx
入口闭包、生命周期和渲染函数都会收到插件上下文对象(Pafish\Services\ExtensionContext):
方法 | 用途 |
|---|---|
| 注册 action 事件,返回注销函数 |
| 注册 filter 过滤器 |
| 注册扩展路由;handler 可传闭包或方法名 |
| 注册供字符串 handler 引用的能力名处理器 |
| 声明设置字段(等价于写在 manifest) |
| 注册前台注入,目标点须在 manifest |
| 注册生命周期: |
| 读取 / 整体替换插件自有数据 |
| 在行锁内读取最新数据并修改,返回修改后的数组 |
| 读写插件设置 |
| 写入插件日志,最多保留 50 条 |
| 渲染插件私有 |
| 取 manifest 声明的逻辑表对应的物理表名 |
| 读取本插件 manifest 已声明的实体字段 |
| 写入 / 删除本插件自己声明的实体字段 |
| 空操作(PHP 请求级渲染,无需调用);保留仅为 API 兼容 |
只读属性:$ctx->kind、$ctx->name、$ctx->apiVersion。
声明核心实体字段
插件可在 plugin.json 的 meta 中为核心实体声明字段。当前支持 post、page、user、comment、micro、category 和 tag;字段按插件命名空间独立保存。文章字段会显示在文章编辑页的“扩展字段”区域,其他实体目前提供存储 API,核心尚未为它们添加编辑表单。
{
"meta": {
"post": [
{"key": "subtitle", "label": "副标题", "type": "text", "maxLength": 120},
{"key": "layout", "label": "布局", "type": "select", "options": {"wide": "宽版", "compact": "紧凑"}, "default": "wide"},
{"key": "featured", "label": "重点推荐", "type": "checkbox", "default": false}
],
"user": [
{"key": "external_id", "label": "外部用户 ID", "type": "number"}
]
}
}支持的类型为 text、textarea、number、checkbox 和 select。字段 key 在同一实体类型内必须唯一;required 可设为布尔值,maxLength 范围为 1 到 10000,select 必须提供 1 到 50 个选项。读取与写入都按 manifest 校验;未声明的键不能通过上下文 API 访问。保存文章时只写入请求中提交的已声明字段,未提交字段保留原值。停用插件不会删除数据;插件卸载选择删除数据时,插件名空间的字段随之清理。
$ctx->setEntityMeta('post', $postId, 'subtitle', '一段副标题');
$subtitle = $ctx->getEntityMeta('post', $postId, 'subtitle', '');插件配置和小数据统一保存在 extension_storage,按扩展类型、扩展名称和数据键隔离。plugin_data:{name} 与 plugin_settings:{name} 是兼容调用的逻辑键名,不再要求每个插件建配置表,也不混入普通站点设置。主题使用同一公共表,但身份空间独立。
追加历史、计数或修改部分数据时,使用 updateData(),避免先读取旧对象、再整体保存时覆盖另一个请求的日志或其他字段:
$ctx->updateData(static function (array $data): array {
$data['views'] = (int) ($data['views'] ?? 0) + 1;
return $data;
});回调接收最新数组并必须返回数组;回调、解码或编码失败会回滚本次修改,损坏的 JSON 不会被静默覆盖。已有事务中使用保存点,最终提交仍由外层事务决定。回调中只处理数据,网络请求和文件操作应放在调用之前或之后。setData() 仍表示整体替换,不能用于保证并发局部更新。
需要分页、条件查询、关联或唯一约束的业务数据,继续通过声明式 schema 和 $ctx->table() 使用业务表。公共 JSON 存储适合有界的小数据;日志和通知历史各保留最近 50 条,不用它堆积无限增长的业务记录。
系统钩子
Action 事件
通过 $ctx->on() 注册:
钩子 | 参数 |
|---|---|
| 用户数组: |
| 用户 ID 数组 |
|
|
| 用户数组: |
| 保存事件数组: |
| 新建文章数据 |
| 更新后的文章数据 |
| 已发布文章数据 |
|
|
| 同 |
| 移入回收站的文章数据 |
| 永久删除后的文章数据 |
| 评论数据 |
| 评论状态变更数据 |
| 回复评论数据 |
| 被删除的评论数据 |
| 微语首次发布数据 |
| 微语每次保存数据: |
| 被删除的微语数据 |
| 直接输出型( |
after_micro_save 在草稿与更新时也会触发(after_micro_publish 只在首次发布时触发)。它的 input 是原始提交数组,扩展注入到微语编辑器的字段(核心不认识的键)从这里读取后自行持久化到插件表。
after_post_reaction 在点赞或收藏状态真正发生切换后才触发;重复请求幂等命中时不触发。匿名点赞的 userId 为 null。完整的事件契约(含载荷字段表、过滤器输入输出和上传管线顺序)见扩展事件钩子契约。
示例:
return static function (ExtensionContext $ctx): void {
$ctx->on('after_post_published', static function (array $post) use ($ctx): void {
$ctx->log('文章已发布:' . ($post['title'] ?? ''));
});
};Filter 过滤器
通过 $ctx->filter() 注册。过滤器的第一个参数是当前值,回调必须返回修改后的值:
return static function (ExtensionContext $ctx): void {
$ctx->filter('frontend_meta', static function (array $meta, array $context): array {
$meta['robots'] = 'noindex';
return $meta;
});
};当前常用过滤器包括:
before_login:登录前检查或修改登录决定before_register:注册前检查或修改注册决定before_comment_submit:评论写入前审核或拦截before_post_save:文章保存前检查或修改保存字段(编辑器保存和 Markdown 导入都会触发)before_micro_save:微语保存前检查或修改保存字段filter_post_content:文章和页面详情渲染前的 Markdown 内容变换;回调接收(string $markdown, array $context),其中上下文统一为type、id、data,必须返回 Markdown 字符串markdown_html:Markdown 渲染成 HTML 之后执行,可做外链处理、代码块增强frontend_meta:修改页面标题、描述、canonical、Open Graph 和 JSON-LDtheme_template_candidates:调整主题模板候选顺序;参数为候选名数组和{template, context, theme}上下文。返回值会经过模板名白名单校验,不能注入文件路径theme_setting_value:每次theme_value()取值时执行,第 2、3 个参数是位置参数$key、$defaultposts_public_visibility_sql:向“文章是否公开”的 SQL 条件追加扩展片段,参数为{alias, include_owned_private};只接受可拼进括号的 SQL 片段post_is_public:决策型过滤器,对单篇文章的公开判定做最终裁决;返回false即视为不可读upload_file:文件写入本地或云存储前的预处理(水印、压缩、扫描)upload_store_to_cloud:接管文件上传并返回云端 URLupload_delete_from_cloud:删除云端文件时收到通知
posts_public_visibility_sql 与 post_is_public 必须成对实现:前者决定列表、归档、RSS、sitemap、API 的查询范围,后者决定单篇详情与互动接口是否可读。只改其中一个会出现“列表看不到、直链能打开”或相反的不一致。
过滤器应始终返回符合原接口约定的数据。需要阻止操作时,请返回带有 allowed: false 的决定数组,并提供清晰的 error 信息。决策类过滤器(before_* 系列)在返回 false 或 allowed: false 后会短路,后续扩展不再执行。
正文过滤器示例:
$ctx->filter('filter_post_content', function (string $markdown, array $context): string {
if (($context['type'] ?? '') !== 'post') {
return $markdown;
}
return str_replace('[site-name]', (string) settings('site_name', ''), $markdown);
});正文过滤器只变换 Markdown,不负责拒绝保存,也不会在编辑器实时预览请求中执行。
微语是核心内容类型:after_micro_publish 和 after_micro_delete 的 payload 包含 id、content、status、publishedAt、isPinned、isPrivate、authorId、media 等字段,插件可直接围绕它扩展,无需检测其他插件是否安装。
前台注入
在 plugin.json 中声明注入位置:
{
"apiVersion": 2,
"injects": ["head", "footer", "sidebar", "comment_form"]
}然后在 extension.php 中通过 $ctx->inject() 提供注入处理器(业务文件里的 renderInjection 也必须由 extension.php 显式引入并注册):
'renderInjection' => function (string $target, object $ctx, array $context): string {
if ($target !== 'footer') {
return '';
}
return '<p class="plugin-note">由示例插件提供</p>';
},支持的注入点:head、footer、sidebar、comment_form、login_form、register_form、post_editor、micro_editor、plugin_setting。
head 注入只允许 script、meta、link 和 style 标签;其他位置请自行保证输出安全。
micro_editor 的上下文是 {micro, isEdit, microId}(与 post_editor 的 {post, isEdit, postId} 对应),渲染在微语编辑器正文下方。图片统一插入正文,不再提供独立媒体地址输入框。注入的字段随表单原生提交,核心不认识的键不会写库,扩展需在 after_micro_save 里从 input 读取后自行持久化。
plugin_setting 的上下文是 {pluginName},渲染在该插件自己的设置页中。所有启用的插件都会收到这个注入点,需要自行判断 pluginName 是否是自己。简单配置应优先使用 settings schema;需要表格、分步操作或多个模块时,可在此输出完整的自定义工作区。纯自定义工作区由插件提供标题、说明和内容,核心不会额外显示空的通用设置表单卡片。
插件设置页统一沿用 pafish 后台外壳、面包屑、字体和控件样式。自定义工作区建议采用以下结构:页面标题和简短说明、使用 .admin-tabs / .admin-tab 的同页模块导航、明确的空状态或数据表格、统一的 .admin-backup-msg 操作反馈。模块导航应使用查询参数保留当前插件设置路由;不要把多个模块挤在一个长表单里,也不要自行复制后台侧栏或全局页头。可参考内置 ai_foundation 的设置视图。
插件设置
在 plugin.json 的 settings 中声明后台设置项:
{
"settings": [
{
"key": "enabled_message",
"label": "提示内容",
"type": "text",
"group": "基本设置",
"default": "欢迎使用 Pafish"
},
{
"key": "show_on_footer",
"label": "显示在页脚",
"type": "switcher",
"default": "1"
}
]
}支持的类型(共 22 种,插件可用全部类型):
text、textarea、password、select、radio、checkbox、switcher、
color、image、slider、heading、subheading、button_set、image_select、
palette、background、border、code、spacing、typography、repeater,
外加 group 折叠容器。选项型字段使用 options 对象。
其中 password 仅插件可用,主题写 password 会被拒绝;其余字段与主题设置共用同一套 schema 与渲染规则,不会分叉。
字段的完整规范(show_if 条件显示与多条件组合、分组路径 "外观/颜色"、
分组导航图标 nav_icon、required / maxlength / pattern 等校验、
group / repeater 的嵌套限制)见 主题开发:设置字段 ——
插件设置与主题设置共用同一套 schema 与渲染,规则不分叉。
插件中读取设置:
$settings = $ctx->getSettings();
$message = (string) ($settings['enabled_message'] ?? '');生命周期
在 extension.php 中通过 $ctx->lifecycle() 注册回调(也可以把回调放在业务文件里再由 extension.php 显式引入):
return static function (ExtensionContext $ctx): void {
$ctx->lifecycle('onActivate', static function (ExtensionContext $ctx): void {
$ctx->log('插件已启用');
});
$ctx->lifecycle('onDeactivate', static function (ExtensionContext $ctx): void {
// 停用时释放外部资源或清理运行状态
});
$ctx->lifecycle('onUninstall', static function (ExtensionContext $ctx): void {
// 卸载前清理自有数据
});
$ctx->lifecycle('onUpgrade', static function (ExtensionContext $ctx, string $from, string $to): void {
// 只在版本号发生变化时执行;首次启用($from 为空)不触发
});
};extension.php 也可以返回业务模块数组,由入口自行把 onActivate / onDeactivate / onUninstall / onUpgrade 键转成 $ctx->lifecycle() 调用;核心不会自动读取数组里的这些键。
生命周期回调的异常会被隔离并写入日志,不会中断请求。onUpgrade 是唯一会让启用流程失败的阶段:升级迁移失败时启用/更新会被回滚。
卸载时系统会在删除插件目录前调用 onUninstall。卸载默认只删除插件代码,plugin_settings:{name}、plugin_data:{name} 和声明式自有表会保留。后台卸载时若明确确认“同时删除数据”,系统才会按 manifest 中登记的表清单删除这些对象;不会按模糊前缀扫描数据库。内置插件(builtin: true)不能卸载,只能停用。
声明式数据表
需要结构化数据时,可在 plugin.json 声明自有表。系统会在启用时幂等创建表、列和索引;清单移除列不会自动删除数据库对象。
{
"schema": {
"tables": [
{
"name": "events",
"columns": [
{"name": "id", "type": "bigint", "autoIncrement": true},
{"name": "message", "type": "text", "nullable": true},
{"name": "created_at", "type": "datetime", "default": "CURRENT_TIMESTAMP"}
],
"primary": ["id"],
"indexes": [{"name": "created_at", "columns": ["created_at"]}]
}
]
}
}物理表名由系统生成(pf_plugin_{name}_{table},插件名中的 - 会转成 _),扩展只能通过 $ctx->table('events') 获取已声明的表名。破坏性迁移写在 onUpgrade($ctx, $fromVersion, $toVersion) 中。
插件页面和页面模板
声明式路由(API v2)
需要接收独立请求时,在 plugin.json 中使用 routes。插件路由默认位于 /plugin/{name}/...,不会覆盖站点自然 URL:
{
"routes": [
{"method": "GET", "path": "/dashboard", "handler": "dashboard", "auth": "login", "response": "html"},
{"method": "POST", "path": "/save", "handler": "save", "auth": "login", "response": "json"}
]
}handler 必须是同一个 extension.php 通过 $ctx->provider() 注册的能力名(也可用 $ctx->route() 传闭包,作为 API v1 兼容写法);处理器接收 ExtensionRequest $request 和插件上下文 $ctx。POST 默认校验 CSRF,auth 支持 guest、login、editor、admin。匿名 POST 还会经过限流。
路由可以按需关闭会话令牌校验。会话令牌与 pafish_session Cookie 绑定,对不携带 Cookie 的调用方(如小程序、原生 App)没有意义;这类路由应改用请求头里的自管凭据鉴权,并由限流兜底匿名写入:
{"method": "POST", "path": "/save", "handler": "save", "auth": "guest", "response": "json", "csrf": false}插件如确需自然路径,必须显式添加 "public": true;启用时系统会检查核心保留路径、主题和其他插件的冲突。处理器可按 response 返回 HTML 字符串、JSON 数组或站内相对重定向。
私有路由地址为 /plugin/{插件名}{path},例如 /plugin/example/dashboard。自然路由使用 path 本身作为站点路径,例如将 /notes 声明为 public: true 后地址就是 /notes。自然路由只注册已启用的插件,并在文章 slug 路由之前匹配。
路由 path 必须以 / 开始,不得以 / 结尾(根路由 / 除外);最多 8 段、总长度最多 160 字节。路径段可用小写字母、数字、下划线和连字符,或使用 {id} 形式的单段参数。自然路由不能占用核心保留路径(如 admin、api、login、micro、themes),也不能与主题、其他插件路由冲突;若固定链接使用单段文章 slug,路径也不能与已发布文章冲突。
处理器收到的 $request 是 ExtensionRequest。读取参数时优先使用类型化方法,避免重复处理缺省值和非法类型:
function listNotes(\Pafish\Services\ExtensionRequest $request, object $ctx): array
{
$page = max(1, $request->queryInt('page', 1));
$keyword = trim($request->queryString('q'));
$enabled = $request->inputBool('enabled', true); // POST body 优先,其次 GET query
$ids = $request->bodyArray('ids');
return ['ok' => true, 'page' => $page, 'keyword' => $keyword, 'enabled' => $enabled, 'ids' => $ids];
}可用 queryString/queryInt/queryBool/queryArray 读取 GET 参数,bodyString/bodyInt/bodyBool/bodyArray 读取 POST 表单,inputString/inputInt/inputBool/inputArray 按 POST 优先、GET 其次读取。数值、布尔值或数组格式不合法时返回传入的默认值;input() 仍保留原有行为以兼容已有插件。类型化读取只负责类型和默认值,输出 HTML 仍须使用 e() 转义,SQL 仍须使用参数绑定。
路径占位参数使用 param() 读取字符串,例如 /note/{id} 配合 $request->param('id');收到后仍应校验业务范围。数组读取只保证结果是数组,不会替插件验证每个元素的类型或内容。
后台菜单和路由
插件可以声明后台入口。admin 路由仍使用同一套 ExtensionController 分发管线,权限只允许复用系统已有 capability,不支持插件自定义 capability:
{
"routes": [
{"method": "GET", "path": "/settings", "handler": "settings", "auth": "login", "response": "html", "admin": true, "capability": "settings.manage"},
{"method": "POST", "path": "/settings/save", "handler": "save", "auth": "login", "response": "redirect", "admin": true, "capability": "settings.manage"}
],
"admin": {
"menu": [
{"route": "/settings", "activePath": "/admin/plugins", "label": "插件设置", "icon": "settings", "group": "system", "capability": "settings.manage", "order": 20}
]
}
}菜单项字段:
字段 | 说明 |
|---|---|
| 必须是同 capability 的 |
| 可选。插件页面在地址栏用的是自己的路由(如 |
| 侧栏文字,最长 80 字符 |
|
|
| 只能是 |
| 必须是 |
| 组内排序,0–10000,越小越靠前 |
菜单只显示激活插件的声明,且继续按当前用户的 Auth::can() 过滤。后台入口地址为 /admin/plugin/{name}{path};GET/POST 的 CSRF、异常隔离和激活状态检查均由核心统一处理。
可用 capability:dashboard.view、posts.manage、pages.manage、taxonomy.manage、media.manage、comments.manage、links.manage、appearance.manage、settings.manage、plugins.manage、store.manage、upgrade.manage、users.manage、backup.manage、transfer.manage、api.manage。EDITOR 默认拥有 dashboard.view、posts.manage、media.manage、comments.manage,USER 只有 dashboard.view,ADMIN 拥有全部。
activePath 同时解决一个常见冲突:插件把设置挂在“插件管理”下时,两者都指向同一批 URL,核心会优先匹配更具体的扩展路径,并让父级系统菜单不再同时高亮。
页面模板
在清单中声明页面模板:
{
"pageTemplates": [
{"name": "card", "title": "卡片页"}
]
}再通过 extension.php 注册 renderPageTemplate($template, $page, $ctx) 处理器,返回动态页面正文 HTML。核心只调用已启用且声明了当前模板标识的插件,并把非空输出交给当前主题模板的 $contentHtml;主题继续负责 header、footer 等布局。普通页面和设为首页的页面均使用这条链路,无需扫描插件目录中的模板文件。
$ctx->provider('renderPageTemplate', static function (string $template, array $page, $ctx): ?string {
return $template === 'card' ? '<section>动态卡片内容</section>' : null;
});返回空字符串或 null 时继续正常正文渲染,异常会记录日志并回退。当前主题存在同名 page-{name}.php 时主题文件优先;多个插件声明同名模板时按启用顺序取第一个非空输出。停用插件后保留页面的原标识,前台回退默认页面布局。需要独立路由或自行控制完整响应的功能,使用下面的插件前台路由接口。
插件前台页面
插件页面统一使用 routes 声明,并在 extension.php 中通过同名 $ctx->provider() 提供处理器。$ctx->route() 仍兼容需要运行时追加路由的旧插件。私有页面地址为 /plugin/{name}/...,自然页面需声明 public: true。
云存储后端
插件在清单中声明 storage.title 后,即成为媒体上传的云端存储后端候选。核心按启用顺序取第一个同时实现 storeFile 的插件:
{
"storage": {"title": "对象存储"}
}return static function (ExtensionContext $ctx): void {
$ctx->provider('storeFile', static function (array $file, ExtensionContext $ctx): ?string {
// $file: buffer/ext/mime/originalName/size/width/height
return MyCloud::put($file['buffer'], $file['ext']);
});
$ctx->provider('deleteFile', static function (string $url, ExtensionContext $ctx): void {
MyCloud::delete($url);
});
$ctx->provider('testStorage', static function (ExtensionContext $ctx): array {
return ['ok' => true, 'message' => '连接正常'];
});
};上传流程依次经过 upload_file 预处理、upload_store_to_cloud 接管、本地存储兜底。存储插件抛错或返回空 URL 时,核心记录日志并回退本地存储,不会让上传整体失败。testStorage 由后台“插件管理 → 测试连接”调用,返回数组显示给管理员。
也可以不用 storage 声明,直接注册 upload_store_to_cloud 过滤器接管任意文件存储;两者不要混用。
安装和发布
将插件目录放入
plugins/,或在后台“应用商店”中安装。检查插件清单是否通过校验。
在后台“插件”页面启用插件并配置设置。
测试前台页面、后台功能和停用后的回退行为。
发布时将插件目录压缩为 zip;压缩包只能包含一个顶层目录,大小不能超过 10MB。
核心隔离约定
插件是独立扩展,不是核心代码的延伸。插件名称以 plugins/ 下的目录名为唯一身份,核心不会为某个具体插件保留别名、特殊分支或数据迁移规则。
优先使用钩子、插件设置和
plugin_data:{name}保存扩展数据,不修改app/、核心表或系统路由。需要持久化结构时,只能使用插件自己的表或数据命名空间,并在
onActivate/onUninstall中负责初始化和清理;不要改写核心表结构。插件入口、生命周期和钩子均有异常隔离。插件停用后应能回到没有该插件的页面状态,不能让核心依赖插件文件存在。
外部请求必须设置超时并处理失败;用户输入、HTML、文件路径和回调名称都要进行白名单校验。
插件需要独立业务页面时声明
routes并在extension.php注册 handler。不要通过覆盖核心文件或动态包含任意路径来扩展功能。
主题只负责展示;需要处理业务、保存数据或接收独立请求时,应实现为插件,避免主题切换影响站点数据和核心流程。
开发建议
使用 API v2,并在
plugin.json中明确声明injects。插件名称、设置键和页面路径一旦发布,尽量保持不变。
所有 HTML 输出和用户输入都要进行转义或严格校验。
不要直接修改 纸鱼博客核心文件;优先使用钩子、主题和插件接口。
外部请求应设置超时,并处理网络失败,不要阻塞页面请求。
不要把密钥写入代码或提交到公开仓库。
更多公共函数和系统类请参阅系统调用和通用函数。