QiLog 主题开发手册
主题决定站点前台的观感:模板结构、样式、侧栏组件、可调项。QiLog 的主题就是一堆普通 PHP 文件 + 一个 theme.json,没有模板引擎、没有编译步骤、没有 Composer 依赖——改完刷新即生效。
本册从「主题放在哪」讲到「打包分发」,所有字段、变量、函数都以当前代码为准。配套还有《插件开发手册》和《用户手册》。
1. 主题放在哪、怎么被加载
| 位置 | 用途 | 是否随安装包分发 |
|---|---|---|
app/resources/themes/ |
内置主题,当前只有 default(Paper) |
是(打包时固定带上) |
data/themes/ |
用户主题,后台「上传主题」装的、或你手工放进去的 | 否(data/ 属于运行时数据,升级不覆盖) |
后台「主题」页会同时扫描这两个目录。开发自己的主题,请直接建在 data/themes/<主题名>/,这样升级覆盖不到它。
主题的启用是一个 option(存 active 主题名),后台点「启用」即刻切换,不需要重启 Web 服务。
三级回退(下一节)意味着:你不需要复制整个内置主题,只放你想改的文件即可,其余自动继承 default。
2. 目录与文件
data/themes/<主题名>/
├── theme.json 主题声明 + 可配置项(必需,缺了会退化成只有目录名的空主题)
├── screenshot.png 后台主题卡片预览图(可选,也认 screenshot.jpg / preview.png)
├── assets/
│ └── theme.css 主题样式(运行时内联进 <head>)
├── base.php 布局母版(可选;页面首行 layout('base') 才会套)
├── index.php 首页(文章列表)
├── archive.php 分类 / 标签 / 归档列表
├── search.php 搜索结果
├── post.php 文章详情
├── page.php 单页详情
├── 404.php 未找到(可选)
├── page-<name>.php 单页模板变体(可选,见 §4.3)
├── post-<name>.php 文章模板变体(可选)
├── _head.php 公共片段:<head> 内容
├── _foot.php 公共片段:页脚 + </body> 前
├── _loop.php 公共片段:列表循环
├── _post_main.php 公共片段:文章主体
├── _page_main.php 公共片段:单页主体
├── _comments.php 公共片段:评论区
├── _sidebar.php 公共片段:侧栏(有坑位时)
├── _functions.php 公共 helper 函数(模板 require;也是 ThemeLoader 的后备文件)
├── functions.php 钩子 / 生命周期回调注册(**引导阶段加载,早于渲染**,可选)
└── widgets/
└── <type>.php 覆盖内置小部件片段(可选)
_ 开头的文件名是社区约定,不是框架要求——它只是让「这是被 include 的片段」一眼可辨,不受任何自动加载约束。你可以自由增删。
3. theme.json 字段
3.1 顶层字段
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
title |
string | 是 | 主题显示名(后台卡片标题) |
version |
string | 是 | 主题自身版本,与 QiLog 应用版本无关 |
author |
string | 否 | 作者 |
author_url |
string | 否 | 作者主页;非 http(s):// 开头会被丢弃 |
description |
string | 否 | 一句话介绍 |
autoescape |
bool | 否 | 默认 false;true 时启用运行时转义兜底(见 §7.3) |
supports |
string[] | 否 | 声明能力:posts / pages / comments / rss / sitemap |
widget_areas |
array | 否 | 小部件坑位,元素为 {key, label} |
default_widgets |
object | 否 | 各坑位的默认组件列表,元素为 {type, title, args} |
settings |
array | 否 | 后台可配置项,见 §3.2 |
最小可用:
{
"title": "我的主题",
"version": "1.0.0",
"author": "你的名字",
"description": "一句话介绍",
"supports": ["posts", "pages", "comments", "rss", "sitemap"]
}
除
theme.json外也认theme.yaml(极简 YAML 解析),但推荐 JSON——内联注释、工具支持都更好。
3.2 settings 元素
| 键 | 说明 |
|---|---|
key |
配置键。落库时拼成 theme_<主题名>_<key> |
group |
后台设置页里的分组标题(同 group 的项聚在一块) |
label |
控件标签 |
type |
控件类型,见 §3.3 |
default |
默认值 |
hint |
控件下方的说明文字 |
options |
type=select 时的「值 => 显示文案」映射 |
preview |
true 时在「草稿态」即时预览(后台改完不必先保存) |
示例:
{
"settings": [
{
"key": "page_width",
"group": "外观",
"label": "整页宽度",
"type": "select",
"options": { "narrow": "紧凑(960px)", "std": "标准(1040px)", "wide": "宽松(1100px)" },
"default": "std",
"hint": "所有页面统一内容区宽度"
},
{
"key": "accent",
"group": "外观",
"label": "主题强调色",
"type": "color",
"default": "#2E6DA3",
"preview": true
}
]
}
模板里读:
<a href="..." style="color: <?= attr($themeConfig['accent']) ?>">阅读全文</a>
3.3 控件类型
| type | 渲染 |
|---|---|
text |
单行输入 |
number |
数字输入 |
select |
下拉(配 options) |
switch |
开关(布尔) |
color |
颜色选择器 |
textarea |
多行文本 |
checkbox |
复选框 |
「单选/下拉」这类可选值型设置必须做同源校验:options 的键集合就是合法值集合,后端保存时会比对——如果你在别处又硬编码了一份选项列表,两边不一致就会出现「后台选不中 / 保存后回默认」。选项只写 theme.json 一处。
4. 模板
4.1 标准文件名
| 文件 | 何时渲染 | 专属变量 |
|---|---|---|
index.php |
首页 / |
$posts、$pager |
archive.php |
/category/{slug} /tag/{slug} /archive/{year}/{month} |
$title、$description、$posts、$pager、$isCategory、$isTag |
search.php |
/search?q=… |
$q、$posts |
post.php |
文章详情 | $post、$comments、$commentCount、$allowComment、$prevPost、$nextPost |
page.php |
单页详情 | $post |
404.php |
未找到 | $message |
没有
links.php,也没有/links前台路由——友情链接功能已在 1.4.1 移除(后期以插件形式提供)。需要「友链」页面时用单页 +page-links.php变体(见 §4.3)。
4.2 三级回退:只覆盖你想改的
View::resolve() 找模板的顺序:
- 当前主题目录:
data/themes/<当前主题>/<name>.php - 内置 default 主题:
app/resources/themes/default/<name>.php - 基础模板:变体(
post-*/page-*)回退到post/page
所以一个主题可以只有 theme.json + assets/theme.css + 两三个模板,剩下全继承。这是官方推荐的开发姿势:先跑起来,再逐个替换。
4.3 模板变体(Template Name)
在主题目录放 page-<name>.php / post-<name>.php,文件头用注释声明显示名:
<?php
/**
* Template Name: 友情链接
* 单页变体模板:宽版沉浸 —— 页头 + 加宽正文排版。
*/
$post = $post ?? [];
- 后台「编辑单页 → 模板」下拉里会出现「友情链接」;
- 只有文件名匹配
page-*/post-*且文件确实存在于当前主题目录时才会出现在下拉里; - 变体的值存进文章的
template字段;选了但文件不存在时静默回退到page/post。
注释头里只写半角标点。全角引号
“”、全角分号;不会被解析,甚至会截断注释。
内置主题自带两个可抄的变体示例:page-links.php(default 与 prism)和 page-tags.php(prism,从数据库实时聚合标签做标签云)。
4.4 布局母版与插槽
页面模板首行声明母版:
<?php layout('base'); ?>
<section class="article-head">
<h1><?= e($post['title']) ?></h1>
</section>
<article class="prose"><?= raw($post['content_html']) ?></article>
整页输出会注入母版 base.php 的 $content。母版与 _head.php / _foot.php 在同一作用域 require,所以页面里算好的变量对它们直接可见。
往 <head> 追加东西用插槽:
<?php layout('base'); section('head_extra'); ?>
<link rel="stylesheet" href="<?= attr(asset_url('assets/theme.css')) ?>">
<?php endSection(); ?>
母版里输出该插槽:<?= section_show('head_extra', $default) ?>。
不写 layout() 的模板与旧版行为一致——不套母版,整页由模板自己负责。
5. 模板变量
5.1 所有页面共享
| 变量 | 内容 |
|---|---|
$site |
name / slogan / description / url |
$headerMenu |
['items' => 两级导航树, 'fallback' => bool],项含 title / url / target / children |
$currentUser |
当前登录用户数组,未登录为 null |
$themeConfig |
theme.json 的 settings 当前值 |
$headerMenu['fallback'] 为 true 表示站点还没配菜单,内容是自动生成的分类列表——你的模板可以据此换个样式,或照常渲染。
5.2 各页面专属
见 §4.1 表格。补充说明两个:
$allowComment:布尔,评论总开关 + 单篇开关 +post_allow_comment钩子三者求与后的结果。评论表单必须用$allowComment守卫,否则「评论已关闭」的文章仍会渲染出表单。$comments:已构建成树的评论数组;$commentCount是总数(含被折叠的回复)。
5.3 列表项与文章项字段
$posts[] 常用键:url、title、excerpt、category、category_url、views、published_at、created_at、is_pinned。
$post 额外含:
| 键 | 说明 |
|---|---|
content_html |
已净化的正文 HTML,原样输出,不要再包 e() |
content_md |
Markdown 源文;富文本文章里为空串(见下方契约) |
tags |
标签数组 |
template |
使用的模板变体名 |
permalink_pattern |
单页自定义 URL 模板(可选) |
正文两列契约(务必遵守):
content_html恒有值,前台直出;content_md只装 Markdown 原文,由富文本编辑器创作的文章里它是空串。 判据:content_md非空 ⇒ Markdown 文章;为空 ⇒ 富文本文章(正文只在content_html)。 ⛔ 不要假设content_md一定非空,也不要往它里面写 HTML;列表上下文里需要摘要/字数时优先用excerpt,别依赖content_md。
6. 助手函数
定义在 app/Kernel/functions.php,全部全局可用(模板、主题函数文件、插件 bootstrap 里都能直接用)。以下是完整清单与真实签名:
| 函数 | 用途 |
|---|---|
e($value) |
HTML 转义(htmlspecialchars);输出用户数据必用 |
attr($value) |
属性转义,语义区分版,用于 HTML 属性值 |
raw($value) |
显式声明按 HTML 原样输出;仅用于已净化 / 内部生成的 HTML |
url(string $path = '') |
站内 URL(自动拼 base_url()) |
asset_url(string $path) |
资源 URL(同样拼 base) |
base_url() |
站点根 URL |
is_https_request() |
当前请求是否 HTTPS |
redirect(string $path, int $code = 302) |
跳转 |
csrf_token() / csrf_field() / check_csrf() |
CSRF:取 token / 输出隐藏字段 / 校验 |
view(string $name, array $data = []) |
渲染片段并显式传参(推荐,比裸 require 少耦合) |
layout(string $name) |
声明布局母版 |
section(string $name) / endSection() |
捕获具名片段供母版插槽消费 |
section_show(string $name, string $default = '') |
母版里输出插槽,取不到时用 $default |
widgets(string $area) |
渲染某坑位的小部件,返回已转义的 HTML 字符串 |
option(string $key, $default = null) |
读站点配置项 |
get_option_json(string $key, $default = []) |
读一个 JSON 格式的配置项(自动解码) |
post_image(array $post, bool $allowFirstImage = false) |
取文章封面图 URL(没有封面时可选回退到正文首图) |
admin_logo_url(?string $stem = null) / admin_logo_full_url() |
后台 logo 地址 |
get_plugin_config($plugin, $key, $default = null) |
读单个插件配置 |
get_plugin_configs($plugin) |
读某插件全部配置 |
Hook 相关(见 §10):add_filter、add_action、apply_filters、do_action。
⚠️ 没有
pager()这个函数。 分页 HTML 由控制器算好后以变量$pager交给模板(已渲染好的字符串)。用法:
<?php if (!empty($pager)): ?>
<nav class="page-nav"><?= $pager ?></nav>
<?php endif; ?>
同样没有全局
db()。需要数据库时请走容器(插件里用$app->get('db'),见《插件开发手册》§10)。
7. 转义纪律
7.1 怎么选,照做就行
| 场景 | 写法 |
|---|---|
| 用户可控数据(标题、昵称、评论…) | <?= e($x) ?> |
| HTML 属性里的值 | <?= attr($x) ?> |
| 已净化的正文 HTML、自己拼的内部 HTML | <?= raw($x) ?> |
| 数字 | <?= (int) $x ?> |
正文 content_html 入库时已 sanitize,模板里必须用 raw() 原样输出,别再包 e()——那样标签会变成可见字符。
7.2 静态自查
php tools/lint-templates.php
扫描所有 <?= 输出是否包了 e() / attr() / raw() / 数字强转。裸变量直出会告警,退出码等于告警数,可直接接进 CI。
7.3 autoescape 运行时兜底
theme.json 里声明 "autoescape": true 后,模板里的裸短输出 <?= $x ?> 会在首次渲染时被自动编译成 <?= e($x) ?>:
- 产物缓存在
data/cache/templates/,源文件一变即失效; - 转义判定与 Lint 共用同一份白名单
app/Kernel/EscapePolicy.php:白名单函数、内部 HTML 变量、显式强转原样保留; - 产物落盘前会过语法校验,损坏自动回退源模板。
Lint 是静态提醒,autoescape 是运行时兜底,两者不冲突。建议两个都开。
8. 小部件(Widget)
8.1 声明坑位
{
"widget_areas": [
{ "key": "sidebar", "label": "侧边组件" },
{ "key": "footer", "label": "页脚组件" }
],
"default_widgets": {
"sidebar": [
{ "type": "recent_posts", "title": "最新文章", "args": { "limit": 5 } }
],
"footer": []
}
}
模板里渲染:
<aside class="col-side"><?= raw(widgets('sidebar')) ?></aside>
widgets() 返回已经渲染并转义好的 HTML,所以外面套 raw()。
8.2 内置 10 种类型
recent_posts、recent_comments、tag_cloud、categories、custom_html、search、archives、popular_posts、random_posts、links。
links类型仍在注册表里,但与已移除的友链模块有关,新主题不要依赖它。
8.3 片段即模板
每个类型对应一个 PHP 片段 app/resources/widgets/{type}.php。可用类型 = 内置 ∪ app/resources/widgets ∪ <主题>/widgets。
主题里建同名 widgets/{type}.php 就能覆盖内置片段——这是改小部件外观的标准做法。
8.4 数据注入与转义
WidgetRenderer::buildData() 按类型把数据注入片段,例如 recent_posts 注入 $posts、$show_date、$title、$widget。
片段自行负责转义(e() / attr()),且不参与主题目录的 autoescape 编译——别指望兜底。
8.5 自定义类型
类型不必是内置的:插件可以用动态钩子注册数据源与参数 schema(见《插件开发手册》§11)。主题侧把它当普通类型填进 default_widgets 即可。
8.6 运营期配置
后台「主题 → 小部件」调整的排序与内容存在 option theme_<主题>_widgets:
{"areas": {"sidebar": [{"type": "recent_posts", "title": "最新文章", "args": {"limit": 5}}]}}
9. 样式与资源
9.1 theme.css 内联
主题样式运行时内联进 <head>(不是外链),所以改完刷新即生效,不需要清缓存,也没有 ?v=filemtime 之类的版本参数。
约定路径 assets/theme.css;在 _head.php 里读进来再输出(内置主题就是这么做的):
<?php
$cssFile = __DIR__ . '/assets/theme.css';
$themeCss = is_file($cssFile) ? (string) file_get_contents($cssFile) : '';
?>
<style><?= $themeCss ?></style>
用 __DIR__ 而不是拼绝对路径 —— 这样无论主题装在 app/resources/themes/ 还是 data/themes/ 都成立。
不要把后台样式混进来——后台样式是另一套(
app/resources/admin/admin.css),与前台完全隔离。
9.2 图片与其他静态资源
放 assets/ 下,模板里用 asset_url() 取地址:
<img src="<?= attr(asset_url('assets/logo.png')) ?>" alt="">
主题静态资源是磁盘真实路径(与插件资源不同,插件资源由 PHP 分发,见《插件开发手册》§9)。所以主题资源在 Nginx 下不需要额外伪静态规则。
9.3 截图
后台主题卡片优先取 screenshot.png,其次 screenshot.jpg、preview.png。建议 1200×900 左右、小于 1MB。
10. 主题里挂钩子
10.1 函数文件怎么被加载
ThemeLoader 在应用引导阶段(早于任何渲染)加载当前激活主题的函数文件,按优先级取第一个存在的:
functions.php(WordPress 惯例,推荐新主题用这个)_functions.php(既有约定,向后兼容)
查找顺序是「用户主题目录 → 内置主题目录」,命中即停。整个请求只加载一次(require_once)。
为什么要这么早加载:有些扩展点必须在渲染前就位,比如 widget_args_*(后台组件参数表单)、front_register_routes、admin_menu_ext。如果只在模板被渲染时才 require,这些就注册不上了。
后台编辑非激活主题的组件配置时,系统会额外把那个主题的函数文件也装一次(同一请求内幂等),否则它的
widget_args_*schema 取不到、表单会是空的。
10.2 注册钩子
<?php
// functions.php
if (!function_exists('add_action')) return; // 防止被直接访问
add_filter('theme_head', function (string $html): string {
return $html . '<meta name="theme-color" content="#3082AB">';
});
add_filter('post_content', function (string $html, array $post): string {
return $html . '<div class="post-notice">转载请注明出处。</div>';
}, 10, 2);
可用的扩展点、语义与优先级规则见《插件开发手册》§5–§6——那份清单是主题和插件共用的。
约定:钩子注册放
functions.php,公共 helper 函数放_functions.php。后者由模板自己require,不属于自动加载范围。
10.3 生命周期回调(可选)
在 functions.php / _functions.php 顶层声明两个约定名字的函数,主题切换时会自动触发:
| 函数名 | 触发时机 |
|---|---|
ActiveTheme_{id}() |
主题启用后(写默认配置、初始化数据等) |
DeactivateTheme_{id}() |
切换到别的主题前(清理等) |
{id} = 主题目录名统一转小写、非 [a-z0-9_] 的字符替换为 _。例如目录名 My-Theme → my_theme:
function ActiveTheme_my_theme(): void {
// 首次启用时补一份默认组件配置
}
function DeactivateTheme_my_theme(): void {
// 收拾自己的临时数据
}
未声明则静默跳过;回调抛异常也不会阻断主题切换。
10.4 注册前台路由
add_filter('front_register_routes', function ($r) {
$r->get('/wall', function () { return '留言墙'; });
return $r;
});
回调必须原样返回 $r,否则后面的注册全部丢失。路由方法有 get / post / any。
10.5 注册 REST API 扩展
主题同样可以经 api_register_routes 暴露自定义端点(如首页微语流、主题专属数据):
add_filter('api_register_routes', function (array $map): array {
$map['mytheme'] = function ($app, array $sub, $guard, string $method) {
return \App\Http\Api\ApiResponse::json(['ok' => true]);
};
return $map;
});
闭包签名与完整示例见《插件开发手册》§6.3.1 与 docs/rest-api-guide.md。
11. 打包、安装与分发
后台「主题 → 上传主题」接受 zip,规则由 ThemePluginService::installFromZip() 强制校验:
- zip 必须只含一个顶层目录,目录名即主题名,如
my-theme/theme.json; - 目录名只允许
字母 / 数字 / _ / -(^[a-zA-Z0-9_-]+$); - 拒绝含
..、绝对路径、盘符的条目(防路径穿越); - 解压到临时目录 → 校验
theme.json存在 → 整体搬入data/themes/<name>/; - 同名目录已存在时先删旧版再装(覆盖安装);
- 不能覆盖正在启用的主题,会提示先切到别的主题。
手工打包(无 zip 扩展时的兜底):
cd data/themes
zip -r my-theme.zip my-theme -x "*.DS_Store" "*/node_modules/*" "*.bak*"
装好后到后台「主题」页点启用。
12. 上线检查清单
- [ ]
php tools/lint-templates.php退出码为 0 - [ ]
theme.json里的version已更新 - [ ] 所有 POST 表单都含
csrf_field() - [ ] 评论区用
$allowComment守卫 - [ ]
screenshot.png已就位 - [ ] 未覆盖的文件确实应该继承(对比
default主题确认行为符合预期) - [ ] 用真实 HTTP 打开首页 / 分类 / 标签 / 搜索 / 文章 / 单页 / 404 各页各看一遍
- [ ] 窄屏(≤640px)过一遍
13. 踩坑与雷区
| 现象 | 原因 / 处理 |
|---|---|
| 改了文件没生效(样式类) | 主题 CSS 是内联的,理论上改完即生效;若仍不对,先确认你改的是当前启用主题目录,而不是另一个同名目录 |
模板里 <?= $x ?> 输出成空白 |
变量名在当前页面不存在。对照 §5 的变量表;$post 与 $posts 是两个不同的东西 |
| 正文标签变成可见字符 | 对 content_html 包了 e()。正文必须 raw() |
Template Name 下拉里看不到变体 |
文件名必须以 page- / post- 开头,且文件在当前启用主题目录内 |
| 注释头之后页面断了 | 注释里用了全角标点。改半角 |
| 启用了别的主题后自己主题的坑位没了 | widget_areas 只在 theme.json 里声明;切换主题时坑位集合跟着换,theme_<主题>_widgets 是按主题分键存的 |
| 覆盖安装报「不能覆盖正在启用的主题」 | 先启用内置 default,再覆盖 |
| 上传 zip 报「应包含单一顶层目录」 | 打包时把目录一起打进去了吗?zip -r x.zip mytheme 才是对的,zip -r x.zip mytheme/* 会把文件散在根部 |
14. 抄得走的配方
14.1 做一个极简主题(只改样式)
data/themes/lite/
├── theme.json {"title":"Lite","version":"1.0.0"}
└── assets/theme.css 覆盖想要的观感
其余模板全部继承 default。适合「内置主题结构满意,只想换皮」。
14.2 首页改用双栏 + 侧栏
复制 default/index.php 到你的主题,改成:
<?php layout('base'); ?>
<div class="grid">
<main class="col-main"><?= view('_loop', ['posts' => $posts]) ?></main>
<aside class="col-side"><?= raw(widgets('sidebar')) ?></aside>
</div>
前提是 theme.json 里声明了 sidebar 坑位。
14.3 文章页加「阅读进度条」
post.php 末尾:
<script>
(function(){var b=document.getElementById('bar');addEventListener('scroll',function(){
var h=document.documentElement.scrollHeight-innerHeight;
b.style.width=(h>0?scrollY/h*100:0)+'%';});})();
</script>
配合 section('head_extra') 注入一小段 #bar 的样式。
14.4 给旧文章加「请勿转载」提示
用钩子,不动模板:
add_filter('post_content', function (string $html, array $post): string {
$months = (time() - strtotime((string)($post['published_at'] ?? 'now'))) / 2592000;
return $months > 6 ? $html . '<p class="old-note">本文已发布超过半年,观点可能过时。</p>' : $html;
}, 10, 2);
14.5 加一个自定义小部件类型
主题目录建 widgets/notice.php:
<?php
/** @var string $title @var array $widget */
?>
<section class="w-notice">
<h3><?= e($title) ?></h3>
<p><?= e($widget['args']['text'] ?? '') ?></p>
</section>
再到后台「主题 → 小部件」里选它。片段顶部拿得到 $title、$widget。
对应 QiLog v1.8.0。文档与代码不一致时以代码为准。
