文档

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() 找模板的顺序:

  1. 当前主题目录:data/themes/<当前主题>/<name>.php
  2. 内置 default 主题:app/resources/themes/default/<name>.php
  3. 基础模板:变体(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-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 表格。补充说明两个:

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) ?>:

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 在应用引导阶段(早于任何渲染)加载当前激活主题的函数文件,按优先级取第一个存在的:

  1. functions.php(WordPress 惯例,推荐新主题用这个)
  2. _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() 强制校验:

  1. zip 必须只含一个顶层目录,目录名即主题名,如 my-theme/theme.json;
  2. 目录名只允许 字母 / 数字 / _ / -(^[a-zA-Z0-9_-]+$);
  3. 拒绝含 ..、绝对路径、盘符的条目(防路径穿越);
  4. 解压到临时目录 → 校验 theme.json 存在 → 整体搬入 data/themes/<name>/;
  5. 同名目录已存在时先删旧版再装(覆盖安装);
  6. 不能覆盖正在启用的主题,会提示先切到别的主题。

手工打包(无 zip 扩展时的兜底):

cd data/themes
zip -r my-theme.zip my-theme -x "*.DS_Store" "*/node_modules/*" "*.bak*"

装好后到后台「主题」页点启用。


12. 上线检查清单


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。文档与代码不一致时以代码为准。

© 2026 QiLog · 基于开源协议发布 · 返回官网首页