Pafish 主题负责站点前台的布局、样式和交互。主题放在站点根目录的 themes/ 下,每个主题使用一个独立目录。
主题目录
主题目录名只能使用小写字母、数字、下划线和连字符,长度为 1 到 50 个字符。
主题采用 PHP 模板优先的结构。theme.json 是主题清单,extension.php 是可选的扩展入口;纯模板主题只需要清单和模板,只有需要钩子、注入、生命周期或自定义路由时才提供入口。主题目录至少再提供一个 PHP 模板或样式/封面文件:
themes/example/
├── theme.json # 主题清单(必需)
├── extension.php # 可选扩展入口(钩子/注入/路由)
├── preview.jpg
├── header.php
├── footer.php
├── index.php
├── post.php
└── assets/
├── css/
│ ├── style.css
│ └── theme.css
└── js/
└── app.jsCSS、JS 等静态资源应统一收进 assets/ 子目录(assets/css/、assets/js/、assets/img/、assets/fonts/),主题根目录只保留 PHP 模板、theme.json 和 preview.jpg,不要在根目录堆放裸资源文件。
主体模板按主题 manifest 的 systemTemplateFallback 决定是否允许回退到 Pafish 系统模板;未声明该字段的旧主题暂按旧规则兼容。新主题应明确列出需要复用的模板,避免主题页面意外混入另一套布局。
preview.jpg 是后台“主题与外观”列表使用的主题封面,建议使用约 800 × 529 的 JPG 图片。没有封面时后台显示统一占位图;为兼容旧主题,preview.png、preview.jpeg、preview.webp、screenshot.jpg 和 screenshot.png 仍可作为回退文件名。
theme.json
主题设置按 theme_settings:{name}:{key} 独立保存;不同主题的同名字段互不覆盖,切换后再切回会恢复各自配置。后台仅允许配置当前主题,保存、导入、导出和恢复默认接口均执行相同检查。主题通过切换启用,不提供独立的停用操作。
升级时 theme_settings.sql 会把原有共享 theme:{key} 设置一次性归入当前主题,并移除旧键;其他主题从自己的默认值开始。已有的新格式设置优先保留。备份仍使用原来的 JSON 格式,导入时按所属主题和字段规则校验。
name 必须与目录名一致。title 和 version 可以省略,系统会分别使用目录名和 1.0.0。主题设置、页面模板、组件区域、文章字段、路由和数据表等能力都属于可选声明:
{
"name": "example",
"title": "示例主题",
"version": "1.0.0",
"description": "一个 Pafish 主题",
"author": "你的名字",
"authorUrl": "https://example.com",
"homepage": "https://example.com/example-theme",
"settings": [
{
"key": "accent_color",
"label": "主题色",
"type": "color",
"group": "外观",
"default": "#2563eb"
}
]
}主题也使用统一的 apiVersion 和 injects 声明。API v2 主题必须明确声明要注入的点位,入口中的 $ctx->inject() 才会输出;可用点位包括 head、footer、sidebar、comment_form、login_form、register_form、post_editor 和 micro_editor。纯模板主题可以不提供 extension.php;只有需要钩子、注入或自定义路由时才需要入口。主题注入只用于主题自己的展示与编辑界面增强,会员、通知、支付等业务仍应由插件提供。
主题可以用 requires 声明本地插件或主题依赖,例如 ["plugin:notify_hub >=1.0.0"]。切换和每次启动都会检查依赖;主题的业务路由仍应只服务于该主题的展示体验,通用业务应放在插件中。
主题缺少主体模板时,核心不会自动混用系统模板。确实要复用系统模板时,在 theme.json 的 systemTemplateFallback 中逐项声明,例如 ["page", "micro"];未声明的页面会按缺失模板处理。
userData 仍可声明(取值限于 posts、stats、reactions、comments、points),但它目前只做清单校验,用户中心的数据由核心按 $mode 统一提供,不再依据该字段裁剪查询。新主题不必声明。
声明核心实体字段
主题可以在 theme.json 的 meta 中声明归属于当前主题的核心实体字段,格式与插件一致:
{
"meta": {
"post": [
{"key": "reading_time", "label": "阅读时长(分钟)", "type": "number", "default": "5"},
{"key": "show_note", "label": "显示主题提示", "type": "checkbox", "default": true}
]
}
}支持 post、page、user、comment、micro、category、tag 实体,以及 text、textarea、number、checkbox、select 字段。文章字段会显示在文章编辑页的“扩展字段”区域;其他实体目前可通过主题上下文 API 存取,尚无核心编辑表单。主题字段独立保存在 theme:<主题名> 命名空间,切换主题时不会覆盖其他主题的数据;卸载并选择删除数据时才清除。
ExtensionContext 提供 $ctx->getEntityMeta($subject, $id, $key, $default = null)、setEntityMeta($subject, $id, $key, $value) 和 deleteEntityMeta($subject, $id, $key)。这些方法只允许访问当前清单已声明的键。
主题设置
settings 中的字段会显示在后台“外观 → 当前主题设置”页面。内核共 22 种类型,主题支持其中 21 种(password 仅插件,写进主题会被拒绝):
类型 | 说明 |
|---|---|
| 单行文本 |
| 多行文本 |
| 复选框 |
| 开关 |
| 下拉选择( |
| 单选组( |
| 取色器(存 |
| 图片(媒体库 / 上传 / 外链) |
| 分节标题(不产生设置值) |
| 次级标题(不产生设置值) |
| 数值滑块( |
| 四向间距( |
| 字体组合( |
| 可重复行( |
| 可折叠区块( |
| 分段按钮组( |
| 图片缩略图当选项( |
| 预置色板( |
| 颜色+图片+重复+位置+尺寸+附着,合成一个 JSON(存 JSON) |
| 宽度+线型+颜色,合成一个 JSON(存 JSON) |
| 等宽代码框( |
| ⚠️ 不是类型:任意字段可加 |
| ⚠️ 仅插件可用,主题写 |
常用字段:
字段 | 说明 |
|---|---|
| 设置键,只应使用稳定、唯一的英文标识 |
| 后台显示名称 |
| 控件类型 |
| 设置分组,支持两级: |
| 分组内的分段标题,用于把长表单切块 |
| 默认值 |
|
|
| 输入提示 |
| 字段下方的常驻说明(帮助站长理解该填什么) |
| 收进 |
分组:一级与二级
group 用路径写法表达层级,一级分组是入口,二级是它下面的子项:
{ "key": "site_icon", "group": "基础", "type": "image", "label": "站点图标" },
{ "key": "link_color", "group": "外观/颜色", "type": "color", "label": "链接颜色" },
{ "key": "body_font", "group": "外观/字体", "type": "typography", "label": "正文字体" },
{ "key": "cache_ttl", "group": "高级", "type": "text", "label": "缓存时长" }左侧导航会长成:
基础
外观 ▸ 展开
颜色
字体
高级只切一层:
"a/b/c"会得到一级a、二级b/c(无损,不报错)一级分组有子项时是纯容器,点击只展开/收起,不承载自己的字段
若某父级既有直属字段又有子分组,直属字段会归入名为「常规」的子项, 而不是被静默丢弃
只有一个去处(单个一级分组、且无子项)时不显示导航,表单直接占满宽度
切到二级时会自动展开其父级;父级下有子项出错时会自动展开并显示错误徽章
这套模型与 CSF / emlog 的
vaimi_options一致:那边是sections扁平表 + 可选的parent,导航由pre_tabs()推导;pafish 没有独立的 section 声明, 分组是从字段的group推导的,所以用路径写法,免得在每个字段上重复声明父子关系。
排版辅助:heading / subheading
它们只用于排版,不产生设置值,因此不受 required 等校验影响:
{ "key": "sec_layout", "label": "布局设置", "type": "heading",
"desc": "下面这些控制页面整体布局", "group": "基础" },
{ "key": "sub_adv", "label": "高级选项", "type": "subheading", "group": "基础" }slider:数值滑块
{ "key": "max_width", "label": "内容最大宽度", "type": "slider",
"min": 600, "max": 1600, "step": 20, "unit": "px", "default": "1100" }滑杆与数字输入框双向联动;保存时校验必须落在 min ~ max 之间。
min 大于 max 时会被自动纠正,避免控件不可用。
spacing:四向间距
{ "key": "card_pad", "label": "卡片内边距", "type": "spacing",
"sides": ["top", "right", "bottom", "left"], "unit": "px",
"default": "{\"top\":\"16\",\"right\":\"20\",\"bottom\":\"16\",\"left\":\"20\"}" }值为 JSON 字符串,留空的边不出现在结果里(便于回退到 CSS 默认):
<?php
$pad = json_decode(theme_value('card_pad', '{}'), true) ?: [];
$css = sprintf('%spx %spx %spx %spx',
$pad['top'] ?? 0, $pad['right'] ?? 0, $pad['bottom'] ?? 0, $pad['left'] ?? 0);
?>
<div style="padding: <?= e($css) ?>">…</div>可用 sides 只声明需要的方向;numeric_limit 限制数值绝对值(默认 999)。
typography:字体组合
{ "key": "body_typo", "label": "正文字体", "type": "typography",
"fields": ["family", "size", "weight", "line_height", "color"],
"default": "{\"family\":\"sans-serif\",\"size\":\"16\",\"weight\":\"400\"}" }fields 可选:family、size、weight、line_height、letter_spacing、
style、transform、color。未在 fields 中声明的属性会被拒绝写入。
<?php $t = json_decode(theme_value('body_typo', '{}'), true) ?: []; ?>
<style>
body {
font-family: <?= e($t['family'] ?? 'sans-serif') ?>;
font-size: <?= e($t['size'] ?? '16') ?>px;
font-weight: <?= e($t['weight'] ?? '400') ?>;
}
</style>repeater:可重复行
{
"key": "links", "label": "友情链接", "type": "repeater",
"title_key": "name", "add_label": "添加链接", "max_items": 5, "min_items": 0,
"fields": [
{ "key": "name", "label": "名称", "type": "text", "required": true, "maxlength": 20 },
{ "key": "url", "label": "网址", "type": "text", "required": true,
"pattern": "^https?://", "pattern_message": "需以 http:// 或 https:// 开头" },
{ "key": "is_friend", "label": "互链", "type": "switcher" }
]
}子字段(
fields)复用与顶层完全相同的 schema 规则:类型、required、maxlength、pattern等全部生效子字段类型仅支持基础类型(不允许嵌套
repeater,防止无限递归)title_key指定用哪个子字段作为每行标题,缺省取第一个子字段每行可上移、下移、删除;
max_items上限 100
<?php $links = json_decode(theme_value('links', '[]'), true) ?: []; ?>
<ul>
<?php foreach ($links as $row): ?>
<li>
<a href="<?= e($row['url'] ?? '#') ?>"<?= ($row['is_friend'] ?? '0') === '1' ? ' rel="friend"' : '' ?>>
<?= e($row['name'] ?? '') ?>
</a>
</li>
<?php endforeach; ?>
</ul>group:可折叠区块
把一组相关设置收进可折叠的容器,适合「高级设置」这类默认不需要动的内容:
{
"key": "grp_advanced", "label": "高级设置", "type": "group",
"desc": "不常用,点开调整", "collapsed": true, "icon": "⚙",
"fields": [
{ "key": "cache_ttl", "label": "缓存时长", "type": "text", "maxlength": 6,
"pattern": "^[0-9]*$", "pattern_message": "只能填数字", "default": "300" },
{ "key": "lazy_load", "label": "图片懒加载", "type": "switcher", "default": "1" },
{ "key": "inner_note", "label": "分组内小节", "type": "heading" },
{ "key": "accent_color", "label": "强调色", "type": "color", "default": "#4786d6" }
]
}容器自身不产生设置值,
theme_value('grp_advanced')永远读不到——要读里面的cache_ttl子字段与顶层走完全相同的规则与校验:
required、maxlength、pattern、show_if、desc全部生效子字段类型仅限基础类型 +
slider+heading/subheading; 不允许嵌套group/repeater/spacing/typography(防止无限递归)collapsed默认false(默认展开,避免用户以为设置丢了)容器内的设置照常导出/导入,卸载主题时也会一并清理
视觉选择型:button_set / image_select / palette
这三种的价值是让站长用眼睛选,而不是读懂 layout-2col-right 这种值再想象效果。
选布局、选配色、选风格时,它们的效率远高于下拉框。
{ "key": "home_layout", "label": "首页布局", "type": "button_set", "group": "布局",
"options": { "one": "单栏", "two": "双栏", "three": "三栏" }, "default": "two" },
{ "key": "skin", "label": "主题色", "type": "palette", "group": "外观",
"options": { "#4786d6": "默蓝", "#2f9e63": "松绿", "#b4543f": "砖红" },
"default": "#4786d6" },
{ "key": "cols", "label": "每行列数", "type": "image_select", "group": "布局",
"preview_width": 120,
"options": { "2": "/themes/xx/img/2col.png", "3": "/themes/xx/img/3col.png" },
"default": "3" }button_set:inline默认true,横向分段按钮image_select:options的值是图片地址;multiple: true可多选(存 JSON 数组)palette:options的键是颜色值,值是提示文字三者都必须给
options,否则该字段会被拒绝并写入runtime/error.log
background / border:把一组 CSS 属性合成一个设置项
避免主题里散落 bg_color / bg_image / bg_repeat / bg_position 四五个键:
{ "key": "page_bg", "label": "页面背景", "type": "background", "group": "外观",
"fields": ["color", "image", "repeat", "position", "size", "attachment"],
"default": "{\"color\":\"#ffffff\",\"repeat\":\"no-repeat\",\"size\":\"cover\"}" },
{ "key": "card_border", "label": "卡片描边", "type": "border", "group": "外观",
"default": "{\"width\":\"1\",\"style\":\"solid\",\"color\":\"#f2f2f2\"}" }background子键:colorimagerepeatpositionsizeattachmentborder子键:widthstylecolor固定取值域的子项渲染成下拉(
no-repeat/center top/solid这些记不住的串不用手填), 且服务端按白名单校验:手填拼错会直接报错,而不是存进去悄悄不生效模板里
json_decode(theme_value('page_bg'), true)后自行拼 CSS
code:代码框
{ "key": "custom_css", "label": "自定义 CSS", "type": "code",
"language": "css", "rows": 8, "default": ".post-card{border-radius:10px}" }等宽字体、Tab 插入两个空格、带「整理缩进」按钮。不引第三方编辑器,避免为一个字段拖进几百 KB。
help:字段帮助气泡(不是字段类型)
任意字段都能加 help。desc 是常驻说明,help 是收进 ? 气泡的补充细节——
长表单里把细节都铺开会非常吵,所以两者分工不同:
{ "key": "page_bg", "label": "页面背景", "type": "background",
"desc": "留空沿用主题默认",
"help": "图片可填媒体库地址或外链;size 选「覆盖」时图片会铺满并裁切。" }字段校验
主题可以声明校验规则,后台输入时会即时提示,保存时服务端再拦一次:
字段 | 类型 | 说明 |
|---|---|---|
| bool | 必填;空值会被拒绝 |
| int | 最大字符数(中文按 1 个字符计) |
| int | 最小字符数 |
| string | 正则(不含定界符),如 |
| string | 正则不匹配时的提示文案,不写则显示默认提示 |
{
"key": "slug_prefix",
"label": "别名前缀",
"type": "text",
"desc": "只能使用小写字母、数字与连字符",
"pattern": "^[a-z0-9-]*$",
"pattern_message": "只能使用小写字母、数字与连字符",
"maxlength": 20,
"group": "链接"
}select/radio会额外拒绝不在options中的值,避免手改表单写入脏数据color始终要求#rrggbb格式校验失败时整批设置都不会写入,后台会精确高亮到出错的字段(即使它在未展开的分组里,也会自动切过去)
正则必须有界:无法编译的
pattern会被直接丢弃,不会让整个表单失效
条件显示(show_if)
show_if 让字段按另一个字段的取值显隐:
{
"key": "dark_soft",
"label": "深色背景柔和度",
"type": "select",
"options": { "soft": "柔和", "hard": "对比" },
"show_if": { "key": "dark_mode", "value": "1", "op": "eq" }
}
| 含义 |
|---|---|
| 等于 |
| 不等于 |
| 取值在 |
| 取值不在 |
| 非空且不为 |
in / notin 需要提供 values 数组。开关与复选框的取值是 "1" / "0"。
多条件组合:需要多个条件同时成立(and)或任一成立(or)时,用 rules 数组:
{
"key": "masonry_cols",
"label": "瀑布流栏数",
"type": "select",
"options": { "2": "两栏", "3": "三栏" },
"show_if": {
"relation": "or",
"rules": [
{ "key": "layout", "value": "masonry" },
{ "key": "layout_home", "value": "masonry" }
]
}
}relation可选and(默认)/or;非法值回退and也支持参考实现的竖线简写,三段按
|切开后逐位对应:
{ "show_if": { "key": "a|b", "op": "eq|ne", "value": "1|2" } }规则全部非法时该字段不会被加上
show_if(而不是产出一条空规则把字段永久隐藏)
分组导航图标(nav_icon)
设置页左侧导航会按 group 自动生成两级分组。想让某个分组带图标,
在该组的任意一个字段上写 nav_icon(取组内第一个声明的值):
{ "key": "h_color", "label": "颜色", "type": "heading",
"group": "外观/颜色", "nav_icon": "palette" }可用图标名见 admin_icon() 的定义(app/Core/helpers.php,共 64 个,
如 paletteimagelayoutsettingsstarlinkusersglobemoon)。
名字不存在时会静默回退到默认字形,不会报错。
注意
nav_icon与字段自己的icon语义不同:后者是这个字段的装饰 (如group折叠区块标题前的图标),不会出现在导航上。
在模板中读取
<?php $accent = theme_value('accent_color', '#2563eb'); ?>
<a style="color: <?= e($accent) ?>">站点链接</a>自定义主题或工具可以用 ExtensionKernel::describeSetting($field, 'theme') 校验单个字段定义,
用 ExtensionKernel::validateSettingValue($field, $value) 校验取值,与后台行为完全一致。
核心识别的保留键
个别设置键由核心直接消费,主题声明同名键即可生效(不声明则保持默认行为):
键 | 类型 | 作用 |
|---|---|---|
|
| 首页文章条数,大于 0 时生效,上限 30(读取点 |
除此之外,前台每页篇数一律跟随后台全局设置 posts_per_page。主题不应自行声明"每页篇数"再让核心读取;如确需主题独立控制,应新增明确的核心过滤器或分页参数契约。
分页
系统默认分页模板位于 app/Views/theme/pagination.php(数字页码 + 首尾省略),主题可创建 themes/{active}/pagination.php 覆盖。可用数据:
变量 | 说明 |
|---|---|
| 当前页码(≥1) |
| 总页数(≥1) |
| 列表页基础 URL,自行拼接 |
分页样式由主题自定:上一页/下一页、数字页码、简洁分页、"加载更多"按钮、无限滚动均可。其中"加载更多"与无限滚动(含游标分页)需要控制器或 API 配合,单靠主题模板无法实现。
文章专属字段
可以通过 editorFields 声明主题使用的文章自定义字段。主题停用后,这些字段数据仍会保留,但不会在其他主题的文章详情页继续展示;切回原主题后即可恢复使用。未被当前主题声明的字段不会自动输出到前台:
{
"editorFields": ["subtitle", "reading_time"]
}页面模板和组件区域
主题的自定义页面模板使用文件约定:在主题根目录放置 page-landing.php,后台页面编辑器即可选择“落地页”,无需修改 page.php 或填写清单。文件头用 @name 注明显示名;未填写时使用文件名中的标识:
<?php
/** @name 落地页 */
get_header();
?>
<main class="landing"><?= $contentHtml ?></main>
<?php get_footer(); ?>选中值仍以 landing 保存到 pages.template。核心在普通页面与“设为首页”的页面中统一优先加载 page-landing.php,找不到则使用 page.php;系统模板回退继续遵循 systemTemplateFallback。模板标识限定小写字母、数字和连字符,最长 40 位,default 保留给默认模板;只发现主题根目录的真实 .php 文件,不扫描子目录或目录外链接。
theme.json 的 pageTemplates 现在只是可选的显示名覆盖,不会为不存在的文件生成选项。显示名优先级为清单 → 文件头 → 文件名。组件区域仍在清单中声明:
{
"pageTemplates": [
{"name": "landing", "title": "落地页"}
],
"widgetAreas": [
{"key": "sidebar", "label": "侧边栏"},
{"key": "footer", "label": "页脚"}
]
}每个页面模板均可使用 $page、$contentHtml 及原有 SEO 上下文。插件提供的动态页面正文由核心分发到 $contentHtml,主题不必额外调用插件服务;主题仍负责页面布局。同名时当前主题文件优先于插件声明。主题应输出 $contentHtml,自行重新渲染 $page['content'] 会绕过内容过滤与动态正文。
切换主题或停用插件后,失效模板在编辑器显示为“暂不可用”,保存其他内容会保留原标识;恢复相应主题或插件后可继续使用。新选择只能来自当前可用选项。
sidebar 是系统默认区域;自定义区域的 key 只能使用小写字母、数字、下划线和连字符。
主题业务路由
主题可以在 theme.json 中声明自然前台路由,只有当前激活主题的路由生效:
{
"routes": [
{"method": "GET", "path": "/works", "handler": "works", "auth": "guest", "response": "html"},
{"method": "POST", "path": "/publish", "handler": "publish", "auth": "login", "response": "redirect"}
]
}需要动态能力的主题业务写在 extension.php 中。路由元数据写在 theme.json,入口通过同名 provider 提供实现;$ctx->route() 只用于旧扩展或运行时追加路由:
<?php
return static function (\Pafish\Services\ExtensionContext $ctx): void {
$ctx->provider('works', static function ($request, $ctx): array {
return ['html' => $ctx->render('works')];
});
};详细接口见统一扩展内核。functions.php、helpers.php、theme.php 可以作为被 extension.php 显式引入的业务文件,但不会被核心自动加载。
主题声明的路由始终是自然路径,只有当前激活主题的路由生效。路由 path 必须以 / 开始,不得以 / 结尾(根路由 / 除外);最多 8 段、总长度最多 160 字节。路径段可用小写字母、数字、下划线和连字符,或使用 {id} 形式的单段参数。主题路由不能占用核心保留路径,也不能与已启用插件路由冲突;若固定链接使用单段文章 slug,路径也不能与已发布文章冲突。
处理器可通过 $request->queryString()、queryInt()、queryBool()、queryArray() 读取 GET 参数,通过对应的 body*() 方法读取 POST 表单;input*() 则按 POST 优先、GET 其次读取。用 param('id') 读取路径参数。详细类型和默认值规则参阅插件路由请求 API。类型化读取不会转义 HTML,也不替代 SQL 参数绑定。
红包、通知、封面等主题专属功能可以在这里实现;需要跨主题复用时再拆成插件。
主题也可声明 schema.tables 使用自己的结构化数据表。当前主题每次请求启动时只检查 schema 的版本和指纹;发生变化时才做幂等增量同步,已启用主题升级后无需重新切换即可补齐新增表、列和索引。主题切换和普通卸载默认保留数据;只有后台卸载时明确选择删除数据,系统才会删除登记的 pf_theme_{name}_* 表、该主题的设置和私有数据。主题模块可通过 $ctx->table($logicalName) 获取已登记的物理表名。
模板文件
主题可以覆盖以下系统模板:
文件 | 用途 |
|---|---|
| HTML 头部、导航和侧边栏 |
| 页脚和全局脚本 |
| 首页文章列表 |
| 文章详情 |
| 单篇文章可选布局,自动出现在文章编辑器的模板选择中 |
| 指定分类文章详情;不存在时回退 |
| 独立页面 |
| 微语列表页( |
| 分类文章列表 |
| 标签文章列表 |
| 搜索结果 |
| 文章归档 |
| 主题自定义用户中心;通过 |
| 404 和错误页 |
系统默认账户中心使用 /admin,由核心统一提供账户、资料和权限相关页面。主题不需要也不应复制一套固定的前台个人中心。
如果主题需要自己的用户中心,可以提供 user.php,核心会把以下路径交给它:/user(用户中心首页)、/user/{route}(主题自定义子页,route 只允许小写字母、数字、下划线和连字符,最长 50)以及 /user/card/{id}(资料卡,需主题设置 profile_card_enable 为 1)。当前主题未提供 user.php 时这些入口全部返回 404,核心不会回退到内置个人中心。
| 触发路径 | 主要变量 |
|---|---|---|
| 未登录访问 |
|
| 已登录访问 |
|
|
|
|
|
|
|
模板可以按 $mode 切换界面。$stats 包含 posts、views、comments;$posts 只包含当前登录用户的公开文章。用户中心的写操作(发布、改资料、上传头像等)应由主题声明路由并在 extension.php 中处理,使用核心 Auth、Upload、Content 与实体字段 API 完成校验和落库,不要把写操作塞进模板。
模板使用 PHP 原生语法,不需要额外模板引擎。建议在模板开头标明用途,并使用 e() 转义输出:
<?php
$post = $post ?? [];
get_header();
?>
<article>
<h1><?= e((string) ($post['title'] ?? '')) ?></h1>
<div class="md-content"><?= $contentHtml ?? '' ?></div>
</article>
<?php get_footer(); ?>正文变量 $contentHtml 已经由系统完成 Markdown 渲染,不要再次进行 HTML 转义。标题、链接、属性和用户输入则必须使用 e()。
常用模板变量
header.php:$title、$description、$og,以及站点设置和导航数据index.php:$posts、$pageNum、$totalPages、$listBaseUrl、$emptyText;访问/?author={id}时额外提供$authorId与$profileUserpost.php:$post、$contentHtml、$locked、$customFields、$related、$prevPost、$nextPostpost-{category}.php:与post.php相同;其中{category}是文章的category_slugpage.php:$page、$contentHtml,其中$page['template']是页面模板标识user.php:$mode(guest/post/profile/card),其余变量随 mode 变化,见上文“用户中心”micro.php:$items(微语数组,含id、content、publishedAt、isPinned、isPrivate、author、media)、$pageNum、$totalPages、$total、$listBaseUrl。登录作者可以看到自己的私密微语并带有isPrivate标识;游客只看到公开内容列表项:文章标题、slug、摘要、封面、发布时间、作者、分类、标签和统计数据
变量的完整形状可以参考 app/Views/theme/ 下的系统模板。
作者动态列表
首页支持 /?author={用户 ID} 查询参数,用于展示某个作者的公开文章。它复用 index.php 模板,控制器额外传入:
变量 | 说明 |
|---|---|
| 当前作者 ID |
| 作者行: |
| 空列表文案,此处为“还没有公开动态” |
列表只包含该作者的公开文章,分页链接已带 author 参数。作者被禁用或不存在时返回 404。主题可以据此在列表顶部渲染作者卡片;不读这些变量也不会出错。
微语后台的图片统一以 Markdown 写入 content。系统默认 micro-card.php 使用 \Pafish\Services\MicroStatuses::contentWithMedia($content, $media),将已有独立 media 图片合入正文并去重;这些微语从后台再次保存后,图片由正文维护,独立 media 清空。自定义模板展示已有独立图片时也可使用该方法,避免正文和图片墙重复展示。
文章详情依次查找当前主题的 post-template-{name}.php(文章所选模板)、post-{category_slug}.php(分类布局)、post.php,之后按主题允许的系统模板回退规则处理。模板候选可通过 theme_template_candidates 过滤器调整:
add_filter('theme_template_candidates', function (array $candidates, array $context): array {
if (($context['template'] ?? '') === 'post') {
array_unshift($candidates, 'post-special');
}
return $candidates;
});过滤器返回的每一项仍会经过模板名白名单校验,非法路径会被丢弃;返回空数组时恢复系统候选顺序。
自定义文章模板
文章的 cover_url 是独立封面数据。核心只有在保存时明确提交 auto_cover=1 且封面为空才从正文首图提取,未提交封面字段则保留原值。默认主题的文章列表展示已有封面;详情页额外展示由主题设置 show_post_cover 控制,默认关闭,正文中的图片不受影响。自定义主题自行决定封面的展示位置,无需采用默认主题设置。
将 post-template-gallery.php 放在主题根目录,即可在文章编辑器选择它,无需修改清单或在 post.php 分支加载:
<?php /** @name 图片文章 */ ?>
<?= render_partial('header') ?>
<article class="gallery-post">
<h1><?= e($post['title']) ?></h1>
<?= $contentHtml ?>
</article>
<?= render_partial('footer') ?>文件头 @name 为可选显示名,未填写时显示 gallery。标识仅含小写字母、数字和连字符,最多 40 字符,default 保留给默认布局。选择存入 posts.template,自动保存、App 保存和内容导入导出均保留它。没有可选文件时编辑器不显示模板选择框。
模板文件必须真实位于主题根目录;不接受目录穿越、目录或指向主题外的符号链接。切换主题或移除文件后,历史选择保留并标注“暂不可用”,前台回退分类或默认布局;恢复文件后重新生效。交互保存拒绝新选不存在的模板,内容包导入则允许保留合法但尚未安装的模板标识。
公共函数
主题模板可以使用以下常用函数:
函数 | 用途 |
|---|---|
| 渲染主题头部和页脚 |
| 渲染局部模板 |
| 生成站内链接,自动适配伪静态和子目录 |
| 生成 |
| 生成主题资源地址 |
| 读取主题设置 |
| 读取站点设置 |
| 将 Markdown 渲染为 HTML |
| HTML 转义 |
静态资源
style.css:主题布局样式;缺失时回退到默认主题布局样式。theme.css:主题专属样式;系统会以内联<style>的方式加载。
两个文件都优先从主题的 assets/css/ 子目录读取,找不到时再回退主题根目录(兼容旧主题布局),因此新旧两种摆放方式都能生效。
其他 CSS、JS、图片和字体放在 assets/ 对应子目录中。在模板中使用 theme_asset_url() 获取资源地址,不要拼接服务器绝对路径:
<link rel="stylesheet" href="<?= e(theme_asset_url('example', 'assets/css/app.css')) ?>">
<script src="<?= e(theme_asset_url('example', 'assets/js/app.js')) ?>" defer></script>代码高亮按需加载
系统前台不再内置 KaTeX、Mermaid 等大型渲染库,Markdown 渲染也不会产出公式/图表标记;核心默认页脚只加载代码高亮 highlight.js。主题若要只在有代码块时加载高亮库,可在 footer.php 下发一份资源清单,由主题脚本检测正文标记后按需注入:
<script>
window.PAFISH_RENDER = {
hljs: <?= json_encode(asset_url('/js/highlight.min.js')) ?>
};
</script>主题脚本在换页(含 PJAX)后扫描内容,按下表决定加载哪个库:
正文标记 | 需要的库 |
|---|---|
| highlight.js( |
同一库的并发加载要做去重,加载失败按已就绪处理(渲染函数自行判断全局对象是否存在);PJAX 换页后 innerHTML 注入的内联脚本不会自动执行,需重建执行一次。参考实现见 themes/marginalia/assets/js/marginalia.js。
主题若需要公式、图表等额外渲染能力,应自行把对应库放进 assets/ 并用同样的清单模式按需加载;或由插件经 filter_post_content 把正文转换为带标记的 HTML,主题脚本再按标记渲染。核心不再为这些库提供内置路径。
插件挂载点
为了兼容插件,主题应保留系统挂载点:
<?php do_action('head_inject', ['template' => $GLOBALS['pafish_tpl_ctx'] ?? []]); ?>
<?php do_action('sidebar_inject', ['template' => $GLOBALS['pafish_tpl_ctx'] ?? []]); ?>
<?php do_action('footer_inject', ['template' => $GLOBALS['pafish_tpl_ctx'] ?? []]); ?>header.php 应保留 head_inject 和 sidebar_inject,footer.php 应保留 footer_inject。删除这些调用会导致插件的前台功能失效。
安装和测试
将主题目录放入
themes/。确认
theme.json的name与目录名一致。在后台“外观”页面检查主题信息并启用。
访问首页、文章、分类、搜索和独立页面,检查桌面与移动端显示。
主题完成后可以压缩为 zip,从后台上传安装或提交到应用商店。
主题压缩包上限为 10MB,压缩包应只包含一个顶层主题目录。主题更新失败时系统会恢复原版本。
核心隔离约定
主题主要负责展示,但也可以通过清单声明主题专属的前台路由、请求处理器和数据表。主题路由与数据应服务于当前主题,不应成为跨主题仍需运行的通用功能。
使用
theme.json声明路由和表结构,通过extension.php处理路由请求,通过主题上下文读写主题设置和数据;不要在模板或业务实现文件中直接修改系统表、创建未登记的表或动态注册路由。可复用于不同主题、主题停用后仍需运行,或需要独立后台管理的功能应实现为插件;主题可以通过钩子读取插件提供的数据。
主题 helper 加载失败会被记录并隔离,核心页面仍应可以渲染。主题应使用带
theme:<name>标记的钩子,便于切换主题时清理。主题停用后,文章、分类、用户和插件数据必须保持不变;主题专属文章字段应只在声明它的主题中消费。
模板输出用户输入时使用
e()转义,资源路径使用theme_asset_url(),不要拼接文件系统路径或动态包含用户可控文件。
判断边界:只服务当前主题的展示与业务可放在主题中;脱离当前主题仍需存在、或供多个主题复用的功能放进插件。
开发建议
不要直接修改
app/Views/theme/中的系统模板。不要把数据库密码、API Key 或其他密钥写入主题。
所有用户输入和设置值都要转义;只有系统生成的
$contentHtml可以按 HTML 输出。保持
theme.json中的设置键稳定,修改键名会使用户之前保存的设置无法自动迁移。
更多公共函数和系统类请参阅系统调用和通用函数。