主题开发指南
最近更新:2026-11-07
主题只管显示,不管业务。一个主题由三部分组成:模板(Twig)、清单(theme.json)、静态资源。内核把"要显示什么"算好,以固定变量交给模板,模板只决定"怎么显示"。
本指南面向主题作者,从目录结构讲到打包上架,末尾附一份最小可运行主题。文中变量名、钩子名、字段名均以当前内核实际实现为准。
0 · 三条底线
- 主题不写业务 —— 不查库、不算数、不下单。业务归插件。
- 模板是 Twig 受限子集,默认自动转义,想写出 XSS 都难。
- 文件名即语义,不许自创。
single.twig就是单篇,index.twig是全站兜底,缺文件由回退链兜住。
受限模板加声明式清单,换来的是可预测、可校验、可继承,代价是不能想写什么就写什么。
1 · 主题目录结构
themes/molin-default/
├── theme.json 清单(必填)
├── templates/ 模板文件
│ ├── index.twig ★ 必需,全站兜底
│ ├── single.twig 单篇文章
│ ├── page.twig 独立页面
│ ├── archive.twig 归档通用壳
│ ├── category.twig 分类页
│ ├── tag.twig 标签页
│ ├── author.twig 作者页
│ ├── date.twig 日期归档
│ ├── search.twig 搜索结果
│ ├── 404.twig 未找到
│ ├── attachment.twig 媒体页
│ └── partials/ 可复用片段
│ ├── header.twig 页头导航(裸结构页用)
│ ├── site-header.twig 全站统一页头(含移动端浮层)
│ ├── footer.twig 页脚双版权槽位
│ └── theme-style.twig 主题设置 → CSS 变量
├── assets/ 静态资源(原样直出,不过 PHP)
│ ├── css/ js/ img/
├── languages/ 语言包(可选,{locale}.json)
└── screenshot.png 预览图(建议 1200×750 / 16:10)
硬约束:
- 目录名即主题 slug,小写字母开头,只含小写字母、数字、连字符(
^[a-z][a-z0-9-]*$),内核按此校验。 templates/index.twig必须存在,自己或父主题链里有都算。assets/下的文件由 Web 服务器直接直出,不经过 PHP。引用时写根相对路径/themes/{slug}/assets/xxx.css,见 §9。- 语言包按域
theme读取,路径themes/{slug}/languages/{locale}.json。 screenshot.png是后台主题卡里的预览图;清单screenshot声明了、且文件真在主题目录里,后台才显示。- 主题钩子入口
functions.php尚未接线(见 §10),当前主题不能靠它注册钩子。
2 · theme.json 清单
清单承载三样东西:主题信息、能力声明、配置项声明。
{
"name": "molin-default",
"title": "墨林·默认主题",
"version": "1.0.0",
"contract": "1.x",
"author": "墨林官方",
"description": "极简、以写作为主的默认主题。",
"license": "MIT",
"parent": null,
"screenshot": "screenshot.png",
"supports": {
"blocks": false,
"darkmode": true,
"custom_logo": true,
"custom_header": true,
"menus": ["primary", "footer"],
"widgets": ["sidebar", "footer"]
},
"settings": {
"colors": {
"label": "配色",
"type": "group",
"fields": {
"ink": { "label": "墨色", "type": "color", "default": "#1B1C1E" },
"paper": { "label": "纸色", "type": "color", "default": "#FAFAF8" },
"accent": { "label": "强调色", "type": "color", "default": "#B96A2E" }
}
}
}
}
2.1 顶层字段
| 字段 | 必填 | 说明 |
|---|---|---|
name | 是 | 主题 slug,必须等于目录名,小写加连字符 |
title | 是 | 展示名,后台列表里给用户看 |
version | 是 | 主题自身版本,与内核版本无关 |
contract | 是 | 契约版本,如 "1.x";上传安装时校验,主版本号与内核 MOLIN_CONTRACT 不符直接拒收(启用与加载时不再校验) |
author | 作者名 | |
description | 一句话简介 | |
license | 许可协议,如 MIT | |
parent | 父主题 slug,null 表示根主题,见 §4 | |
screenshot | 预览图文件名;没有默认值,不写就不出预览图;写了且文件真在主题目录里才显示 | |
supports | 能力声明,见 2.2 | |
settings | 主题配置项声明,见 §7 |
> version 必须随内容升(2026-10-11 增): 主题内容有变化就同时升这个号 —— 加模板、改模板、改 assets/、改 settings 声明都算。举例:官方主题 molin-default 加了 templates/page-archive.twig 与配套 assets/page-archive.css/page-archive.js 后,version 从 1.0.1 提到 1.0.2(此后每次改动继续按此规则递增,当前随包主题已是 1.0.7)。升级通道靠它判断"主题有没有新版";漏升版本,老站点升级时看不出主题更新,新增文件也送不到(「页面模板」下拉会整个不出现,见 §15)。
2.2 supports 能力声明
| 键 | 含义 |
|---|---|
blocks | 支持区块编辑 |
darkmode | 支持暗色模式 |
custom_logo | 支持自定义 Logo |
custom_header | 支持自定义页头(背景图 / 标题栏) |
menus | 支持的菜单位置,如 ["primary","footer"] |
widgets | 挂件区域,如 ["sidebar","footer"] |
这些键当前内核只登记、未消费 —— 不会据此自动生成后台入口。先声明着不影响运行;内核接入后才会按声明给设置入口。
3 · 模板命名与回退链
3.1 命名表(固定,不许自创)
| 模板文件 | 对应页面 | 必需 |
|---|---|---|
index.twig | 首页 / 全站兜底 | 是 |
front-page.twig | 站点首页(优先级高于 home) | |
home.twig | 文章列表首页(博客流) | |
single.twig | 单篇文章 | |
page.twig | 独立页面 | |
archive.twig | 归档列表(通用壳) | |
category.twig | 分类页 | |
tag.twig | 标签页 | |
author.twig | 作者页 | |
date.twig | 按日期归档 | |
search.twig | 搜索结果 | |
404.twig | 未找到 | |
attachment.twig | 媒体页 |
命名规则:全小写加 kebab-case。自定义内容类型用 single-{type}.twig,如 single-product.twig;其归档用 archive-{type}.twig,如 archive-product.twig。
3.2 回退链
找不到就近的文件,就依次向上游找,最终兜到 index.twig。所以只写想改的那几个文件是合法且推荐的用法,少写文件不会崩。
| 页面 | 回退顺序 |
|---|---|
| 博客首页 | front-page.twig → home.twig → index.twig |
| 静态首页(首页显示 = 某独立页面) | front-page.twig → page.twig → index.twig |
| 单篇 | single-{type}-{slug} → single-{type} → single → index |
| 页面 | page-{slug} → page-{id} → page → index |
| 自定义类型归档 | archive-{type} → archive → index |
| 附件 | attachment.twig → index |
| 分类 | category-{slug} → category-{id} → category → archive → index |
| 标签 | tag-{slug} → tag-{id} → tag → archive → index |
| 作者 | author.twig → index |
| 日期 | date.twig → index |
| 搜索 | search.twig → archive → index |
| 404 | 404.twig → index |
{type} {slug} {id} 由内核按当前请求填好。找不到任何候选会抛异常,不会静默白屏。附件的链条只有两段(attachment.twig → index),不提供 single-attachment 中段。
4 · 子主题
子主题只写要改的那个文件,其余自动沿用父主题。
// themes/my-child/theme.json
{
"name": "my-child",
"title": "我的子主题",
"version": "1.0.0",
"contract": "1.x",
"parent": "molin-default"
}
themes/my-child/
└── templates/
└── single.twig ← 只覆盖这一个,其余全用父主题的
解析顺序:子主题 → 父主题 → …… → 默认主题。同一模板名,子主题优先。父链成环会被内核当场拒掉(A → B → A 报错)。
当前限制:没有跨主题的 {% extends "父模板" %}。 模板按"子主题目录在前"的顺序查找,同名文件会先命中子主题自己,写 {% extends "single.twig" %} 会解析到自己的 single.twig,形成自引用。子主题要改某页,就把整份模板抄过来改;想减少重复,把公共部分抽进 partials/。
设置也继承:子主题默认继承父主题的 settings,同名键覆盖,见 §7.3。
5 · 模板可用变量
变量名以内核实际注入为准,见 core/Theme/Renderer.php 与 index.php 各渲染点。
5.1 全局变量(所有页型都有)
| 变量 | 类型 | 说明 | |
|---|---|---|---|
site | object | name、description、url、public(搜索引擎可见)、logo(当前恒为 null)、copyright(内核按 © {年} {站点名} 生成) | |
current_user | User\ | null | 当前登录用户,未登录为 null |
csrf_token | string | 防跨站令牌的值;未登录且未建会话时为空串(文章页评论开启时,内核会为访客建会话并挂上令牌) | |
csrf_field | string | 防跨站令牌的字段名,内核常量,照抄别写死字符串 | |
query | object | 当前查询上下文,query.name 是命中的路由名 | |
menus | object | menus.primary / menus.footer,各是 [{title, url, children}] | |
settings | object | 主题声明字段的默认值(扁平,键为字段名)加 show_credit;用户实际配置请用 theme_setting(),见 §7.3 | |
features | object | 能力开关:social / notifications 两个 bool,默认都 false,由插件(如 molin-social)打开;插件停用时主题据此隐藏关注/赞踩/通知铃(2026-10-22 增) | |
commenter | object | 访客上次评论的昵称/邮箱/网址(2026-10-31 增,来自 HttpOnly 记忆 cookie);没评论过三个键都在、值全空,评论框渲染时可直接画头像和昵称 | |
avatar_template | object | 头像地址的模板前缀/后缀 {prefix, suffix}(2026-10-31 增);站点头像源不是 custom 时两个值都是空串,主题据此退回默认图 | |
head_output | Markup | 页头倒出口,内核汇总好的可信 HTML,放 <head> 里原样输出 | |
footer_output | Markup | 页尾倒出口,放 </body> 前原样输出 |
settings 只含默认值、不含用户保存值 —— 这是最容易踩的一点。要取用户在后台配的颜色/开关,一律走 theme_setting('组.键')。
head_output / footer_output 是变量,不是函数。内核在渲染前把站点图标、RSS 订阅发现链、theme/head/render 钩子的输出算好交给你。你只需原样输出:不要自己触发钩子,不要自己拼 <link rel="alternate">,不要加 |raw(内核已标记可信)。
用变量而非函数,是为了保证只有一份。函数会把"何时执行插件代码"交给主题,主题在循环里调 5 次就吐 5 份统计代码。
<head>
...
{{ head_output }}
</head>
<body>
...
{{ footer_output }}
</body>
5.2 列表页变量(首页 / 分类 / 标签 / 作者 / 日期 / 自定义类型归档)
| 变量 | 类型 | 说明 | |
|---|---|---|---|
items | array | 列表条目,见下。内核自带主题用这个 | |
pager | object | 分页数据,见下 | |
term | object\ | null | 分类/标签/作者/日期页的词条对象,含 name、description |
author | object | 仅作者页:{initial, count} | |
posts / pagination | — | 旧变量名,仅首页注入(posts 是原始 Post 数组;pagination 为 {page, per_page, total, total_pages}),保留给第三方主题;分类/标签/作者/日期页只有 items/pager。内核自带主题已改用 items/pager |
items 元素字段:
| 字段 | 类型 | 说明 | |
|---|---|---|---|
num | string | 序号,两位数补零,如 "01" | |
url | string | 条目链接(站内相对路径) | |
title | string | 标题 | |
published_at | string | 发布时间(UTC),渲染时用 `\ | date(...)` 本地化 |
comments | int | 评论数 | |
category | object\ | null | 首个分类 {name, url},无则 null,键始终在 |
excerpt | string | 摘要;"列表显示=全文"时是已渲染 HTML;文章没写摘要时自动从正文取前 200 字(2026-10-16 增,超了加省略号),所以列表行不再是空白 | |
excerpt_html | bool | 为 true 时 excerpt 是可信 HTML,需 `\ | raw` |
thumbnail | array\ | null | 特色图数据数组(形状见下),无则 null,键始终在 |
views | int | 浏览量(来自 posts.view_count,默认 0;键始终在) | |
comments_preview | array | 最近 3 条已通过评论(2026-10-16 增;无评论 = 空数组,键始终在),元素 {id, author_name, avatar_url, content_text, created_at},content_text 是纯文本;搜索页不带这个键 |
> 列表项新增 comments_preview(2026-10-16 补记,只增不改): 首页 / 分类 / 标签 / 作者 / 日期 / 自定义类型归档的每个列表项
> 都带"最近 3 条已通过评论";整页一次查询,主题不必自己取数。显不显、显几条由主题设置决定。契约权威表述见
> docs/10-契约/13-主题目录约定.md §6.3。
>
> 首页取文范围(2026-10-16 增): 首页 = post + 声明了 in_main_list 的自定义类型(如插件 molin-note 的「说说」);
> 分类 / 标签 / 作者 / 日期这些归档仍只取 post。主题不用改代码,items 的形状不变。
excerpt 与 excerpt_html 首页与归档族(分类/标签/作者/日期页)都有;列表项另有 thumbnail 键。
> 首页也带摘要、列表项新增 thumbnail(2026-10-13 补记,只增不改): 此前 excerpt / excerpt_html 只有归档族有、首页条目不喂摘要,取 item.excerpt 会是空;现在首页与归档族一样都带这两个键 —— 首页列表与归档族走同一段 $listSnippet(index.php 94–101 行),首页 items 也带 excerpt / excerpt_html。
> 首页 / 分类 / 标签 / 作者 / 日期 / 自定义类型归档 / 搜索的每个列表项都多一个 thumbnail 键 —— 特色图数据数组,或 null(键始终在);形状 ['url' => string, 'srcset' => string, 'alt' => string, 'width' => int, 'height' => int, 'sizes' => array<string,['url'=>string,'width'=>int,'height'=>int]>]。没有特色图 / 附件不是图片 / 文件不在盘上时为 null,模板据此落回无图版式;image() 助手可直接吃这个数组(见 §9 与《14》§5.2)。契约权威表述见 docs/10-契约/13-主题目录约定.md §6.3;程序依据 index.php 各列表渲染点(如 $listPage 的 'thumbnail' => $thumbs[$post->thumbnail_id] ?? null,index.php:287),数据由 $thumbsFor(index.php:116–145)经 MediaRepository::imageData() 产出。
> 列表项新增 views(2026-10-14 补记,只增不改): 首页 / 分类 / 标签 / 作者 / 日期 / 自定义类型归档 / 搜索的每个列表项再多一个 views 键 —— 浏览量(int,来自 posts.view_count,默认 0,键始终在);主题可据此显示「N 次浏览」,计数由内核维护、主题只读。契约权威表述见 docs/10-契约/13-主题目录约定.md §6.3;程序依据 core/Content/Post.php:41 的 view_count 字段与 index.php 各列表渲染点(搜索项见 core/Search/Searcher.php)。
pager 对象字段:
| 字段 | 类型 | 说明 | |
|---|---|---|---|
prev | string\ | null | 上一页 URL,无则 null |
next | string\ | null | 下一页 URL,无则 null |
pages | array | [{n, url, current}] 页码列表 | |
count | string | 计数文案(满页),如「共 12 篇 · 每页 10 篇」 | |
count_compact | string | 计数文案(紧凑),如「共 12 篇」 |
{% for item in items %}
<article>
<div class="num">{{ item.num }}</div>
<h2><a href="{{ item.url }}">{{ item.title }}</a></h2>
<div class="meta">
{% if item.category %}<a href="{{ item.category.url }}">{{ item.category.name }}</a> / {% endif %}
<time>{{ item.published_at|date('Y 年 n 月 j 日') }}</time> · {{ item.comments }} 条评论
</div>
</article>
{% else %}
<p>还没有内容。</p>
{% endfor %}
分页地址一律由内核算好(pager 内部走路由名 path() 生成),模板不拼 URL。
> 自定义类型归档(2026-10-12 增,只增不改):has_archive=true 的自定义内容类型有自己的归档列表页(/{rewrite.slug},含 /page/{n} 分页),喂的也是同一套 items / pager / term:页头 term.name 取类型的 label、term.description 为空串、term.slug 为类型名;行元信息不带分类(item.category 恒 null)。URL 段由类型的 rewrite.slug 定(/products → 单篇 /products/{slug})。
5.3 单篇 / 页面变量
| 变量 | 类型 | 说明 | ||
|---|---|---|---|---|
post | Post | 当前内容对象,见 5.5 | ||
category | object\ | null | 单篇的首个分类 {name, url},页面无 | |
tags | array | 单篇的标签列表,元素 {name, url};没打标签就是空数组(键始终在);页面无 | ||
author | string | 当前内容作者的名字(显示名优先、回落用户名);取不到是空串(键始终在,单篇与页面都有) | ||
byline | string | 单篇署名行(作者 · 阅读约 N 分钟) | ||
pager | object | 单篇的上/下篇:`{prev:{title,url}\ | null, next:{title,url}\ | null}` |
subs | array | 独立页面的子页列表:[{title, url}] | ||
comments | array | 评论树(两级嵌套) | ||
comment_total | int | 评论总数 | ||
comment_action | string | 评论表单提交地址;评论关闭时该键不注入,模板用 is defined 判断 | ||
reply_to | int | 被回复的评论 id,0 = 顶层 | ||
comment_notice / comment_error | string | 评论提交后的反馈文案 | ||
form_ts | string | 表单时间戳,反垃圾用 |
> tags(2026-10-11 增,只增不改):url 由路由名 tag 生成,形如 /tag/{slug}(中文别名按路由既有机制做百分号转义);只在 type=post 的单篇页有(独立页面不带)。分类仍在眉标,文末只列标签。
私密正文(密码保护)未解锁时,内核改喂 locked=true、locked_hint、unlock_action,模板此时只渲染口令表单,正文、评论区、上下篇都不渲染。
<article>
<h1>{{ post.title }}</h1>
<div class="body">{{ post.content|raw }}</div>
</article>
{% if pager is defined and pager %}
<nav class="pager">
{% if pager.prev %}<a href="{{ pager.prev.url }}">← {{ pager.prev.title }}</a>{% endif %}
{% if pager.next %}<a href="{{ pager.next.url }}">{{ pager.next.title }} →</a>{% endif %}
</nav>
{% endif %}
文末标签(正文之后、评论之前;只在有标签时渲染,只列标签,不重复列分类):
{% if tags is not empty %}
<div class="tags">
<span class="lab">标签</span>
{% for tag in tags %}<a href="{{ tag.url }}">{{ tag.name }}</a>{% endfor %}
</div>
{% endif %}
5.4 其他页型变量
| 页型 | 变量 |
|---|---|
| 搜索 | keyword、items(元素含 title_html/excerpt_html,可信 HTML)、total、page、pages、per_page、ms |
| 404 | suggest(对象 {title, url} 或 null) |
| 附件 | heading、media(见下)、sizes([{name, edge, url}])、references([{title, url, type_label, date_text, usage_label}]) |
media 对象字段:url、srcset、alt、caption、file_text、size_text、mime、width、height、created_text、uploader。盘上无真文件时 url 为空串,模板走线稿占位。
5.5 Post 对象字段
id, type, status, title, slug, excerpt, content, published_at, updated_at,
thumbnail_id, url, author_id, comment_status, comment_count
content是已渲染的正文 HTML(可信富文本),允许|raw。excerpt是纯文本摘要;列表显示=全文时改为渲染后 HTML。published_at为null表示无发布时间(如私密口令页那支)。
6 · Twig 受限子集
当前内核未启用 Twig 沙箱;"受限"来自两处:自动转义常开,且默认不向模板暴露 PHP 函数。下列标签与过滤器是可以用的集合。
6.1 能用的标签(tag)
extends, block, endblock, include, use, embed, set, if, elseif, else, endif,
for, else, endfor, macro, import, from, autoescape, verbatim, with, apply, do(受限)
6.2 能用的过滤器(filter)
escape, e, raw(受限), default, length, upper, lower, title, capitalize,
trim, replace, date, date_modify, number_format, round, abs,
join, split, slice, first, last, reverse, sort, merge,
keys, values, url_encode, nl2br, striptags, truncate
nl2br(换行转 <br>)在默认主题的首页 hero 文案里用到过。Twig 3 已移除 filter / spaceless 标签(分别由 apply 和 CSS 取代),故不在白名单内。
> truncate(2026-10-12 注册): 自定义过滤器,按字符截断、超长补省略号,已注册于 Renderer::TWIG_FILTERS(core/Theme/Renderer.php:61),《14》§3.2/§5.5 已列;此前本清单漏列。
6.3 禁用的 PHP 函数(白名单外一律不可用)
exec, shell_exec, system, passthru, popen, proc_open, eval,
include, require, file_get_contents, file_put_contents, unlink,
fopen, curl_*, header, setcookie, session_*, mysqli_*, PDO,
call_user_func, call_user_func_array, create_function, assert
6.4 红线
| 禁止 | 原因 |
|---|---|
| 查数据库 | 数据由内核/插件传给模板 |
| 写业务逻辑(下单、计算、校验) | 业务归插件 |
| 调用白名单外函数 | 安全 |
include 任意路径 | 只能 include 主题内模板 |
| 读写文件 | 安全 |
| 直接输出未转义变量 | XSS |
模板能做的只有:取变量、判断、循环、包含片段、调用白名单助手。
6.5 |raw 的使用纪律
|raw只用于可信内容(内核已渲染的富文本,如post.content)。- 禁止
{{ user_input|raw }}。上架校验会扫描raw用法并报警,人工复核。
6.6 区块渲染(未接线)
规划中的区块渲染写法:
{% for block in content_blocks %}
{% include "blocks/" ~ block.type ~ ".twig" with {block: block} only %}
{% endfor %}
块类型规划为 title / richtext / image / gallery / quote / code / embed / html,找不到 blocks/{type}.twig 就回退 blocks/fallback.twig。当前内核尚未注入 content_blocks,先别依赖。
7 · 主题设置
用户在后台调配色和开关,主题不需要写任何后台代码。
7.1 声明(theme.json 的 settings)
按「分组 type: group → 字段 fields」两层结构声明。每个字段必须有 default,否则上传校验不通过。
"settings": {
"colors": {
"label": "配色",
"type": "group",
"desc": "三处颜色决定整站的墨、纸、点睛。",
"fields": {
"ink": { "label": "墨色", "type": "color", "default": "#1B1C1E" },
"paper": { "label": "纸色", "type": "color", "default": "#FAFAF8" }
}
},
"layout": {
"label": "布局",
"type": "group",
"fields": {
"sidebar": { "label": "显示侧栏", "type": "bool", "default": false },
"width": { "label": "内容宽度", "type": "select", "default": "medium",
"options": { "narrow": "窄", "medium": "中", "wide": "宽" } },
"columns": { "label": "列表列数", "type": "range", "default": 1,
"min": 1, "max": 3, "step": 1 }
}
}
}
7.2 字段类型
后台按 type 渲染控件、按类型校验。当前实际支持:
| type | 控件 | 值类型 | 附加参数 |
|---|---|---|---|
text | 单行输入 | string | maxlength、placeholder |
textarea | 多行 | string | rows、maxlength |
bool | 开关 | bool | — |
select | 下拉 | string | options(必填,不在选项内的值存不进) |
color | 取色器 | string(#RRGGBB) | — |
range | 滑块 | int | min、max、step |
number | 数字 | int/float | min、max、unit |
image | 媒体库选图 | int(附件 id) | — |
code | 代码框 | string | lang |
repeater | 可增删列表 | array | fields(子字段) |
通用参数:label、default(必填)、hint(控件下方说明,建议把默认值写进去)。
store: "option" 是内核级参数:该字段值写进站点选项而非主题设置,用于内核要保留的位(如程序版权位 show_credit),主题删不掉。导入导出时这类字段不随主题走。
7.3 取值(theme_setting())
取值顺序:用户设置值 → 主题声明的 default → 调用方兜底。PHP 与 Twig 同名同源。
<style>
:root {
--ink: {{ theme_setting('colors.ink') }};
--paper: {{ theme_setting('colors.paper') }};
--width: {% if theme_setting('layout.width', 'medium') == 'narrow' %}1080px{% else %}1480px{% endif %};
}
</style>
点路径取法,如 'colors.ink'。第二个参数是都取不到时的兜底值。主题必须能用默认值跑起来,未配置时给出的是合理的默认外观,不是空白。
子主题设置继承:子主题默认继承父主题 settings,声明同名键即覆盖。
// 子主题 theme.json
"settings": { "colors": { "fields": { "accent": { "default": "#8A4B1F" } } } }
7.4 导入 / 导出
后台「主题设置」页可把当前配置导出为 JSON,或导入一份预设,用于多站同步外观、备份、交付客户。store: "option" 的字段不参与导入导出。
8 · 全站导航与页脚槽位
所有页面共用同一套页头导航与页脚。主题不得为不同页面换导航项或改页脚版权位。
8.1 页头导航
- 结构:左为站点名/字标,右为主导航(横排),下方一条发丝线。
- 数据源:取菜单位置
primary(menus.primary),不硬编码。后台没配菜单时,primary默认是站点的已发布独立页面。 - 桌面与移动端一致:导航项的集合、顺序、文案完全相同。移动端用「两条杠」按钮展开液态模糊浮层,浮层只是呈现容器,不是第二套导航。
{# partials/site-header.twig #}
<header>
<a class="wordmark" href="{{ site.url }}">{{ site.name }}</a>
<nav class="primary-nav">
{% for item in menus.primary %}
<a href="{{ item.url }}">{{ item.title }}</a>
{% endfor %}
</nav>
<button class="menu-btn" id="menu-btn" type="button" aria-label="菜单" aria-expanded="false">
<span></span><span></span>
</button>
</header>
<hr class="rule">
<div class="overlay" id="menu-overlay">
<button class="close-btn" id="menu-close" type="button" aria-label="关闭"></button>
<nav class="overlay-nav">
{% for item in menus.primary %}
<a href="{{ item.url }}">{{ item.title }}</a>
{% endfor %}
</nav>
</div>
8.2 页脚双版权槽位
页脚固定左右两栏,两处版权语义不同,不得合并:
| 位置 | 内容 | 语义 | 数据来源 |
|---|---|---|---|
| 左下角 | 网站版权 | 这个站属于谁 | site.copyright(内核按 © {年} {站点名} 生成,年份动态) |
| 右下角 | 程序版权 | 这套程序由谁开发 | 主题直接写死 Powered by MolinInk |
- 程序版权必须保留、常显,主题不得删除、遮挡、篡改。内核没有提供关闭它的开关(
settings.show_credit恒为true),主题照抄下面这行即可。 - 桌面端与移动端均为左右两栏,不堆叠,只缩字号。
{# partials/footer.twig #}
<footer class="site-footer">
{% if menus.footer is not empty %}
<nav class="footer-nav">
{% for item in menus.footer %}<a href="{{ item.url }}">{{ item.title }}</a>{% endfor %}
</nav>
{% endif %}
<div class="copy-site">{{ site.copyright }}</div>
<div class="copy-program">Powered by <a href="https://molinink.com" rel="noopener">MolinInk</a></div>
</footer>
9 · 静态资源
静态资源由 Web 服务器直接直出(vhost 走 try_files,真实文件不经 PHP)。写法:
{{ inline_style('home.css') }} {# 页面样式:内联进 <head> #}
<script src="{{ asset('shell.js') }}" defer></script> {# 外部脚本:自动带版本号 #}
asset(path):根相对路径/themes/{slug}/assets/{path},自动加版本号(取资源文件自身的修改时间,文件一改即变),防"新页面配旧缓存"。inline_style(path):把该 CSS 原样内联进文档(默认主题的页面样式一律内联)。理由:外链样式表阻塞渲染,它晚到的那段时间浏览器会先画出"没有版式"的半成品 —— 2026-10-09 第 65 轮已复现并截图。图片、脚本仍走asset()由服务器直出。
主题改 slug 时,asset() 自带 slug;只有直接写死路径的地方需要同步。
image() 已注册(2026-10-13,见《14》§5.2),可直接吃列表项 / Post 的缩略图数据数组或附件 id,生成带 srcset 与懒加载的 <img>;can() 已于 2026-10-12 注册(见《14》§5.7),truncate 过滤器同日注册。内核仍未提供 icon():core/Icon/ 目前只有 .gitkeep,没有 registry.php,全仓也没有 function icon((2026-10-12 全仓核实;规划见《15-图标契约》与《126-图标包基准实现规格》)。主题要图标请自带 SVG 或图片(放 /themes/{slug}/assets/... 按根相对路径引用);要权限判断用 can(),不再需要"暂在模板外处理"。
10 · 主题生命周期钩子
theme 域共 8 个钩子已在内核登记。其中一部分内核会真的触发,另一部分只登记、尚未接线:
| 钩子 | 类型 | 状态 |
|---|---|---|
theme/activate/before | action | 已接线(切换主题前) |
theme/activate/after | action | 已接线(切换主题后) |
theme/setting/after | filter | 已接线(主题设置保存后) |
theme/head/render | action | 已接线(输出汇总进 head_output) |
theme/footer/render | action | 已接线(输出汇总进 footer_output) |
theme/template/replace | filter | 已接线(2026-10-12:模板选中后经此 filter,非法返回值一律忽略) |
theme/features/collect | filter | 已接线(汇总进模板的 features 变量,插件据此开/关社交与通知入口;插件要挂它,记得写进 hooks 声明) |
theme/sidebar/collect | filter | 已登记,未接线(挂件层已删,别指望它执行) |
注册入口:主题的 functions.php 尚未装载。 当前这些钩子只能由插件注册;主题侧入口待内核接入。
theme/head/render / theme/footer/render 的输出由内核汇总后交给 head_output / footer_output 变量,主题模板只输出变量,不得自行触发钩子。
11 · 校验、打包与上架
11.1 上传校验(安装时自动跑)
内核在上传解包后、移入 themes/ 前逐项校验,任一不过即整体拒收,不留半成品:
1. theme.json 合法?name 合规? → JSON 合法,name 匹配 ^[a-z][a-z0-9-]*$
2. title / version 非空? → 两者都要有
3. templates/index.twig 存在? → 主题包内必须有
4. contract 兼容? → 主版本号与内核 MOLIN_CONTRACT 一致
5. parent 存在且无循环? → 父链可解析、不成环
6. 模板语法通过 Twig 编译? → 逐个 .twig 过解析器
7. settings 默认值齐全? → 每个字段都要有 default
包级别另有三条硬闸:体积 ≤ 5 MB、文件数 ≤ 2000、包内不许带脚本文件。
导航与页脚的硬规定(用 menus.primary、双版权槽位、程序版权不得删改)是规范要求,但当前上传器未自动检查,靠作者自守与人工复核。
11.2 打包与上传
- 主题包为
.zip,根目录直接放theme.json(或整套裹在唯一一层目录里,内核会自动识别)。 - 体积上限 5 MB,文件数上限 2000。
- 包内不许带脚本文件:
.php及.py/.sh/.bat/.exe/.phtml/.inc等一律拒收。 - 路径穿越(
..、盘符、绝对路径)当场拒掉。 - 上传入口:后台 → 外观 → 主题 → 上传主题。
molin-my-theme.zip
├── theme.json
├── templates/…
├── assets/…
└── screenshot.png
11.3 发布前自检清单
[ ] theme.json 的 name 与目录名一致、小写连字符
[ ] title / version / contract 齐,contract 与内核主版本一致
[ ] templates/index.twig 存在
[ ] 每个 settings 字段都有 default
[ ] 模板无禁用函数、raw 只在可信位置
[ ] header 用 menus.primary、footer 双版权槽位齐全
[ ] 桌面/移动端导航一致
[ ] 未配置时用默认值也能正常显示
[ ] 所有文件带作者版权信息(见 §13)
12 · 最小可运行主题
一个能跑的最小主题只要 2 个文件。
themes/hello/theme.json
{
"name": "hello",
"title": "Hello 主题",
"version": "1.0.0",
"contract": "1.x",
"author": "你的名字",
"description": "最小可运行主题。"
}
themes/hello/templates/index.twig
{# Hello · 作者 你的名字 #}
<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ site.name }}</title>
{{ head_output }}
</head>
<body>
<header>
<a href="{{ site.url }}">{{ site.name }}</a>
<nav>
{% for item in menus.primary %}<a href="{{ item.url }}">{{ item.title }}</a>{% endfor %}
</nav>
</header>
<main>
{% for item in items %}
<article>
<h2><a href="{{ item.url }}">{{ item.title }}</a></h2>
<time>{{ item.published_at|date('Y-m-d') }}</time>
</article>
{% else %}
<p>还没有内容。</p>
{% endfor %}
</main>
<footer>
<div class="copy-site">{{ site.copyright }}</div>
<div class="copy-program">Powered by <a href="https://molinink.com" rel="noopener">MolinInk</a></div>
</footer>
{{ footer_output }}
</body>
</html>
只有 index.twig 也能跑:单篇、分类、标签……全部回退到它。想改哪页就加对应模板文件,逐页覆盖。
13 · 版权信息规范
每个文件都要带作者版权信息,位置固定、简短即可。
Twig 模板,顶部注释:
{# 主题名 · 作者 你的名字 · https://你的站点 #}
PHP 文件:
<?php
/**
* 主题名
* @author 你的名字
* @link https://你的站点
*/
declare(strict_types=1);
JS / CSS:
主题名 · 作者 你的名字 · https://你的站点
14 · 当前内核的实际可用项
写主题时以当前内核实际注册的助手与注入的变量为准。
已注册的 Twig 助手:
path, asset, inline_style, theme_setting, __, _e, format_date, format_number, avatar, archive_tree, can, image,
post_meta, media_url, html_lang
html_lang()(2026-10-23 增)输出当前界面语言标签(zh-Hans / zh-Hant / en),<html lang="…"> 用它,别写死:
<html lang="{{ html_lang() }}">
另有自定义过滤器 truncate(2026-10-12 注册)。
其中后两个是 2026-10-14 新增,读自定义字段与附件地址用:
{# post_meta(文章 id, 字段键, 默认值):读 postmeta;键没登记过或值为空就给默认值,不报错 #}
{% set seo_title = post_meta(post.id, 'zaxu-toolkit_seo_title') %}
<title>{{ seo_title is not empty ? seo_title : post.title }}</title>
{# media_url(附件 id):回图片地址串(给 og:image 这类要地址的地方);取不到给空串 #}
{% set cover = media_url(post_meta(post.id, 'zaxu-toolkit_seo_image')) %}
> 字段键由登记它的插件决定(形如 {插件名}_{字段},见《34》§4)。主题读别的插件的字段时,
> 键名是自己的选择;插件没启用时读到空串,按上面的写法回落即可。签名的完整说明见《14》§5.8。
仍未注册的,先用替代写法(2026-10-13 更新:image() / can() / truncate 已注册,故从下表移除):
| 未注册项 | 替代写法 |
|---|---|
icon() | 用根相对路径 /themes/{slug}/assets/... 或自带 SVG |
is_* 判断函数 | 用 query.name 等变量替代 |
几处容易踩的点:
settings全局变量只含主题声明的默认值,不含用户保存值;取用户配置走theme_setting()。- 首页
items与归档族(分类/标签/作者/日期页)都带excerpt/excerpt_html(2026-10-13 更正:此前写"首页不含",与现程序不符);列表项另有thumbnail键。 - 评论关闭时
comment_action不注入,用is defined判断。 - 程序版权常显不可关闭;
site.copyright由内核生成。 - 子主题没有跨主题
{% extends %}。
遇到不确定,先读 core/Theme/Renderer.php(变量与助手注册处)、core/Theme/Fallbacks.php(回退链)、core/Theme/ThemeSettings.php(设置取值)与 index.php(各页型渲染点)。
15 · 自定义页面模板
主题可以声明若干「页面模板」,让站长在后台给某张独立页面单独换一套版式(例如一张归档页)。文章不走这条路,只有独立页面(type=page)有。
15.1 声明
在 templates/ 下放一个 .twig,文件开头写一行标记注释,它就成了一个页面模板:
{# molin:template 归档页 #}
- 关键字
molin:template后到#}之间是下拉里显示的名字,可含空格与中文;名字留空则这个文件不算数。 - 只扫
templates/这一层,不递归子目录;只认*.twig。 - 子主题同名文件优先,父主题那份不重复出现;子主题没声明的,沿用父主题的声明。
- 标记必须在文件最开头,且名字要写上。
15.2 后台怎么出现
打开「页面」编辑器,「页面模板」下拉出现在「父页面」那一组里,首项是「默认模板」,其后是本主题声明过的模板。选中项以文件名存进该页的 page_template 自定义字段。主题一个模板都没声明时,这个下拉盒不出现;文章编辑器没有这个盒。
15.3 前台怎么被选中
独立页面渲染时,若该页的 page_template 命中本主题声明过的文件名,就用那个模板;否则按 §3.2 的回退链找。这个比对是对着主题真实声明过的清单做的,所以数据库里存的值不会指向主题目录外的文件。
两条边界:
- 首页「首页显示 = 静态页面」那条路不套页面模板,仍走它自己的回退链。
- 没声明任何页面模板时,前台行为跟没有这功能时一样。
15.4 archive_tree() 助手
写归档类模板时会用到 archive_tree():它按发布时间把全部已发布文章聚成「年 → 月 → 日」的树,倒序排列。未接入数据源时返回空数组。
{% set tree = archive_tree() %}
{% if tree is not empty %}
<ul class="years">
{% for y in tree %}
<li>
<span>{{ y.year }} · {{ y.count }} 篇 · {{ y.preview }}</span>
<ul class="months">
{% for m in y.months %}
<li>
<span>{{ m.month }} 月 · {{ m.count }} 篇</span>
<ul class="rows">
{% for row in m.rows %}
<li><a href="{{ row.url }}">{{ row.day }} 日 {{ row.title }}</a>{% if row.category %} · {{ row.category }}{% endif %}</li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
</li>
{% endfor %}
</ul>
{% else %}
<p>还没有文章。</p>
{% endif %}
返回形状:
list<array{
year: int, count: int, preview: string,
months: list<array{
month: int, count: int,
rows: list<array{ day: int, title: string, url: string, category: ?string, category_slug: string }>
}>
}>
preview是该年各月的速览串,升序,形如02 · 03 · 04(只含出现过的月)。months降序;rows按日降序;category是该文第一个分类名,没有则null。
> 归档树每行新增 category_slug(2026-10-14 补记,只增不改):
> 上表 rows 的每一行新增 category_slug(string;该文没有分类时为空串 ''),即该文首个分类的别名,可安全用于生成链接。
> 原有的 row.category(分类名,没有则 null)保持不变。
> 主题若用它生成链接,请用 path('category', { slug: row.category_slug }),不要再拿 row.category(名称)当 slug —— 别名与名称不同的站会链空。
> 依据:core/Theme/Renderer.php 的 archiveProvider()(firstTermsFor() 返回的就是 array{name,slug},此前只取了 name,见 core/Content/TermAssigner.php 的 firstTermsFor())。
默认主题 molin-default 自带的 templates/page-archive.twig(标记名「归档页」)就是用这个助手写的年 → 月 → 文章三级折叠(默认全收起),配套 assets/page-archive.css、assets/page-archive.js,可直接拿来改。