主题
ZMBlog · 模板开发文档
适用版本:ZMBlog v2.3.1 阅读对象:主题开发者、想自定义站点外观的站长
目录
1. 主题结构
一个主题是 themes/ 下的一个目录:
themes/
└─ 你的主题名/
├─ theme.json 必需:元信息、模板清单、配置声明
├─ index.html 文章列表 / 首页
├─ post.html 单篇文章
├─ page.html 独立页面
├─ archive.html 归档页
├─ category.html 分类页(可选,不声明则复用 index.html)
├─ tag.html 标签页(可选)
├─ search.html 搜索页(可选)
├─ password.html 密码验证页
├─ 404.html 404 页
├─ functions.js 可选:主题函数
└─ _header.html 可选:以 _ 开头的文件作为可被 {{> }}引入的片段目录名即主题标识,启用时填这个名字。
1.1 theme.json 完整字段
json
{
"name": "默认主题",
"author": "ZMBlog",
"version": "2.0.0",
"description": "三栏布局,演示模板引擎与主题配置的全部能力",
"menu": ["首页", "归档", "分类", "标签"],
"templates": [
{ "name": "index", "file": "index.html", "desc": "文章列表 / 首页" },
{ "name": "post", "file": "post.html", "desc": "单篇文章" },
{ "name": "page", "file": "page.html", "desc": "独立页面" },
{ "name": "archive", "file": "archive.html", "desc": "按年月分组归档" },
{ "name": "category", "file": "index.html", "desc": "分类页(复用 index)" },
{ "name": "tag", "file": "index.html", "desc": "标签页(复用 index)" },
{ "name": "search", "file": "index.html", "desc": "搜索结果(复用 index)" },
{ "name": "password", "file": "password.html", "desc": "文章密码验证页" },
{ "name": "404", "file": "404.html", "desc": "404 页面" }
],
"config": [
{ "name": "accent", "label": "主题色", "type": "text", "default": "#2f6feb" },
{
"name": "showSidebar",
"label": "显示侧边栏",
"type": "select",
"default": "1",
"options": [
{ "value": "1", "label": "显示" },
{ "value": "0", "label": "隐藏" }
]
},
{ "name": "footerText", "label": "页脚附加文案", "type": "text", "default": "" }
]
}字段说明
| 字段 | 必需 | 说明 |
|---|---|---|
name | 是 | 主题显示名 |
author | 否 | 作者 |
version | 否 | 版本号 |
description | 否 | 描述 |
menu | 否 | 主题导航项标签 |
templates | 是 | 模板清单。name 是路由逻辑名(内核按此查找),file 是实际文件名。多个逻辑名可指向同一文件 |
config | 否 | 主题配置字段声明,见第 11 节 |
templates 必须覆盖的逻辑名
index、post、page、password、404 是核心模板,建议全部声明。archive、category、tag、search 可省略,省略时内核会回退到 index。
1.2 模板名解析
文章的「模板」字段填的是 templates 中的 name。例如某篇文章设为 page,则渲染 page.html。字段留空时,文章用 post,独立页面用 page。
2. 快速上手:复制一套主题
bash
cp -r themes/zm-default themes/my-theme
cd themes/my-theme改 theme.json 里的 name 与 author,然后在后台「主题」页启用 my-theme。
之后修改 index.html、functions.js 即可,刷新前台立即看到效果。
3. 模板引擎语法
模板引擎是自研的极简实现,性能好、无依赖、无求值风险(不执行任意表达式)。
3.1 输出变量
html
{{ site.title }} 自动 HTML 转义
{{{ post.content }}} 原样输出,不转义
{{ post.categories[0].name }} 数组下标默认转义是强制的。 只有在输出已经是安全 HTML 时才用 {{{ }}}(如 post.content、site 之外的插件注入内容)。
3.2 条件
html
{{#if post}}
<h1>{{ post.title }}</h1>
{{else}}
<p>暂无内容</p>
{{/if}}
{{#unless post.allowComment}}
<p>本文已关闭评论。</p>
{{/unless}}
{{#if keyword}}
<h2>“{{ keyword }}” 的搜索结果</h2>
{{/if}}支持运算符:== != > < >= <= && || 与前置 !。
- 数组作为条件时,非空数组即为真
{{#if title}}等价于「title 有值」- 同一个
if内的嵌套块会被正确识别,else之后的内容在条件为假时才输出
3.3 循环
html
{{#each posts}}
<article>
<h2><a href="{{ permalink }}">{{ title }}</a></h2>
<time datetime="{{ createdIso }}">{{ createdDate }}</time>
{{#if categories}}
<ul>
{{#each categories}}
<li><a href="{{ permalink }}">{{ name }}</a></li>
{{/each}}
</ul>
{{/if}}
</article>
{{else}}
<p>还没有文章。</p>
{{/each}}循环内可用变量
| 变量 | 含义 |
|---|---|
| 当前元素本身的各字段 | 直接访问,如 {{ title }} |
$index | 当前序号,从 1 开始 |
$first | 是否第一项 |
$last | 是否最后一项 |
{{else}} 在数组为空时输出。
3.4 with
把一个对象作为临时作用域:
html
{{#with post}}
<h1>{{ title }}</h1>
<div>{{ content }}</div>
{{/with}}3.5 引入子模板
html
{{> _header }}
{{> _sidebar }}被引入文件由 _ 前缀标记。子模板共享当前作用域,可直接访问外层的 post、page 等变量。
3.6 变量赋值
html
{{= url = post.permalink }}
<a href="{{ url }}">permalink</a>3.7 调用函数
html
{{ $this.paginate(page, pageCount, 'index.php', {}) }}
{{ $this.commentCount(commentTotal) }}
{{ $this.readingTime(post.readingMinutes) }}$this 指向内核 Widget,上下文对象为 kernel.widget。函数可用多参数,字符串参数用单引号。
函数返回值默认转义;若函数返回的是 HTML,需要在函数内部完成转义控制,或让模板用 {{{ }}} 包裹输出:
html
{{{ $this.paginate(page, pageCount) }}}4. 可用变量
每个页面渲染时,内核会注入以下变量。
4.1 全局变量(所有页面)
| 变量 | 说明 |
|---|---|
site.title | 站点标题 |
site.description | 站点描述 |
site.url | 站点地址 |
site.author | 作者昵称 |
site.footer | 页脚文案 |
options | 全部站点配置项的键值对象,如 {{ options.pageSize }} |
themeConfig | 当前主题的配置值,如 {{ themeConfig.accent }} |
themeMeta | 当前主题的 theme.json 内容 |
pluginData | 插件通过 render 动作注入的数据 |
user | 当前登录用户对象,未登录为 null |
pathname | 当前请求路径 |
query | 当前查询参数对象 |
csrf | 当前用户的 CSRF 令牌 |
$this | Widget 对象,可调用其全部方法 |
$options | 同 options |
__fns | 主题函数表,通常无需直接访问 |
4.2 列表页变量
首页、归档、分类、标签、搜索页共用:
| 变量 | 说明 |
|---|---|
posts | 文章数组,每项是完整文章对象 |
page | 当前页码 |
pageSize | 每页数量 |
total | 总条数 |
pageCount | 总页数 |
hasPrev / hasNext | 是否有上/下一页 |
prevUrl / nextUrl | 上/下一页地址 |
pages | 页码数组,含 page 与 url |
title | 页面标题 |
tree | 归档树(仅归档页) |
category / tag | 分类 / 标签对象(对应页面) |
keyword | 搜索关键词(搜索页) |
4.3 文章页变量
| 变量 | 说明 |
|---|---|
post | 文章对象 |
comments | 评论树 |
commentTotal | 评论总数 |
title | 文章标题 |
parent | 父评论 ID(回复表单用) |
4.4 其他
| 变量 | 页面 |
|---|---|
year / month | 按年月归档页 |
archives | 全站归档(配合 $this.archiveTree()) |
5. 主题函数
functions.js 导出一个对象,其键值对成为模板里可通过 $this.<名> 调用的函数。
this 的绑定:functions.js 中的 this 指向内核 Widget,因此可直接调用 this.pageUrlOf()、this.categories()、this.archiveTree() 等 Widget 方法。
js
'use strict';
module.exports = {
/** 格式化文章日期 */
fmtDate: function (ts, fmt) {
const d = new Date(Number(ts) || 0);
const pad = (n) => String(n).padStart(2, '0');
return fmt === 'ymd'
? `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
: `${d.getFullYear()} 年 ${d.getMonth() + 1} 月`;
},
/** 截断文本 */
brief: function (text, len) {
const s = String(text || '').replace(/<[^>]+>/g, '');
const n = Number(len) || 80;
return s.length > n ? s.slice(0, n) + '…' : s;
},
/** 简易导航高亮 */
navClass: function (target) {
return this.pathname === target ? ' class="active"' : '';
},
};在模板中:
html
{{ $this.fmtDate(post.created, 'ymd') }}
{{ $this.brief(post.excerptText, 60) }}函数可返回 HTML,此时用 {{{ }}} 输出,或在函数内自行保证转义。
5.1 分页函数(内置推荐实现)
paginate 需要根据当前路由生成页码链接,因此依赖 Widget 的 pageUrlOf:
js
paginate: function (page, pageCount, baseExpr, values) {
if (!pageCount || pageCount <= 1) return '';
const expr = baseExpr || 'index.php';
const vals = values || {};
const link = (p) => `<a class="pg" href="${this.pageUrlOf(expr, vals, p)}">${p}</a>`;
let html = '<nav class="pagination">';
html += page > 1
? `<a class="pg" href="${this.pageUrlOf(expr, vals, page - 1)}">上一页</a>`
: '<span class="pg disabled">上一页</span>';
const from = Math.max(1, page - 2);
const to = Math.min(pageCount, page + 2);
if (from > 1) html += link(1) + (from > 2 ? '<span class="pg disabled">…</span>' : '');
for (let p = from; p <= to; p++) {
html += p === page ? `<span class="pg active">${p}</span>` : link(p);
}
if (to < pageCount) {
html += (to < pageCount - 1 ? '<span class="pg disabled">…</span>' : '') + link(pageCount);
}
html += page < pageCount
? `<a class="pg" href="${this.pageUrlOf(expr, vals, page + 1)}">下一页</a>`
: '<span class="pg disabled">下一页</span>';
return html + '</nav>';
}各页面传不同参数:
html
<!-- 首页 -->
{{{ $this.paginate(page, pageCount, 'index.php', {}) }}}
<!-- 分类页 -->
{{{ $this.paginate(page, pageCount, 'index.php/[category]/[slug:slug]', { slug: category.slug }) }}}
<!-- 标签页 -->
{{{ $this.paginate(page, pageCount, 'index.php/[tag]/[slug:slug]', { slug: tag.slug }) }}}
<!-- 归档页 -->
{{{ $this.paginate(page, pageCount, 'index.php/[archives]', {}) }}}6. 内核 Widget 方法
以下方法无需在 functions.js 中声明,可直接通过 $this.<名> 调用。
6.1 站点信息
| 方法 | 返回 |
|---|---|
$this.siteTitle() | 站点标题 |
$this.siteDescription() | 站点描述 |
$this.siteUrl() | 站点地址 |
$this.authorName() | 作者昵称 |
$this.option(key, default) | 任意配置项 |
6.2 数据查询
| 方法 | 说明 |
|---|---|
$this.categories() | 全部分类(含文章数) |
$this.tags() | 全部标签 |
$this.archives(limit) | 最新文章 |
$this.all('post') | 全部已发布文章(文章或 'page') |
$this.pages() | 全部独立页面 |
$this.findPost(slug) | 按别名取文章 |
$this.findCategoryBySlug(slug) | 按别名取分类 |
$this.findTagBySlug(slug) | 按别名取标签 |
$this.listCommentsOf(cid) | 取某文章评论 |
$this.fieldsOf(cid) | 取自定义字段 |
$this.archiveTree() | 按年月分组的归档树 |
$this.recentComments(n) | 最近 n 条评论 |
6.3 URL 生成
| 方法 | 说明 |
|---|---|
$this.pageUrl(page) | 分页链接(按当前上下文推断) |
$this.pageUrlOf(expr, values, page) | 显式指定路由表达式生成链接,推荐 |
$this.postPermalink | 文章对象上的 permalink 字段已按站点永久链接风格生成 |
务必使用
pageUrlOf。它会根据当前启用的伪静态规则生成正确的地址,无论你把分页放在哪个路由下。直接手写/page/2只在默认规则下成立,一旦管理员改了规则就会失效。
6.4 内容处理
| 方法 | 说明 |
|---|---|
$this.wordCount(text) | 字数(中文按字、拉丁按词) |
$this.readingTime(minutes) | 阅读时长文案 |
$this.commentCount(n) | 评论数文案 |
$this.statusBadge(status) | 状态徽章(草稿/待审/私密) |
$this.stickyBadge(sticky) | 置顶徽章 |
7. 文章对象字段全表
post 与 posts 数组中的每一项都包含以下字段。
7.1 原始字段
| 字段 | 说明 |
|---|---|
cid | 文章 ID |
title | 标题 |
slug | 别名 |
text | 正文 Markdown 原文 |
type | post(文章)或 page(独立页面) |
status | publish / draft / pending / private |
created | 创建时间(毫秒时间戳) |
modified | 修改时间(毫秒时间戳) |
sticky | 是否置顶 |
allowComment | 是否允许评论 |
password | 访问密码(无密码为空) |
numComments | 评论数 |
template | 指定模板 |
order | 排序权重 |
authorId | 作者 ID |
7.2 派生字段
| 字段 | 说明 |
|---|---|
content | 正文渲染后的 HTML(已过插件过滤器) |
excerptText | 摘要文本 |
excerptManual | 摘要是否由作者手动指定 |
wordCount | 字数 |
readingMinutes | 预计阅读分钟数 |
createdDate | 格式化日期,如 2026-10-14 |
createdTime | 格式化时间,如 14:30:00 |
createdIso | ISO 8601 时间串,用于 <time datetime> |
createdHuman | 相对时间,如「3 天前」 |
modifiedDate | 修改日期 |
permalink | 永久链接(按站点风格生成) |
pagePermalink | 同 permalink |
categories | 分类数组,每项含 cid / name / slug / permalink |
tags | 标签数组 |
fields | 自定义字段对象 |
prev / next | 上一篇 / 下一篇(可能为 null) |
务必用
createdIso而不是created输出<time datetime>。后者是 13 位毫秒时间戳,会在页面源码中暴露原始时间戳。参考内置主题写法:html<time datetime="{{ post.createdIso }}">{{ post.createdDate }} {{ post.createdTime }}</time>
8. 分页
分页元数据已在所有列表页就绪:
html
{{#if hasPrev}}
<a href="{{ prevUrl }}">上一页</a>
{{/if}}
{{#each pages}}
{{#if active}}<span class="active">{{ page }}</span>
{{else}}<a href="{{ url }}">{{ page }}</a>{{/if}}
{{/each}}
{{#if hasNext}}
<a href="{{ nextUrl }}">下一页</a>
{{/if}}或直接用主题函数:
html
{{{ $this.paginate(page, pageCount, 'index.php', {}) }}}每页数量在「设置 → 阅读设置 → 每页文章数」中配置。
9. 评论
comments 是树形结构,顶层评论的子回复在其 children 数组中。
html
{{#each comments}}
<div class="comment" id="{{ anchor }}">
<div class="c-head">
<strong>{{ author }}</strong>
<time>{{ date }} {{ time }}</time>
</div>
<div class="c-body">{{ text }}</div>
{{#if children}}
<div class="c-children">
{{#each children}}
<div class="comment" id="{{ anchor }}">
<div class="c-head">
<strong>{{ author }}</strong>
<time>{{ date }} {{ time }}</time>
</div>
<div class="c-body">{{ text }}</div>
</div>
{{/each}}
</div>
{{/if}}
</div>
{{else}}
<div class="empty">还没有评论,来说点什么吧。</div>
{{/each}}评论字段
| 字段 | 说明 |
|---|---|
coid | 评论 ID |
author | 昵称 |
mail | 邮箱(不应输出到页面,有隐私风险) |
url | 访客填写的网站 |
text | 评论内容(已过滤) |
date / time | 格式化日期 / 时间 |
createdIso | ISO 8601 时间串 |
createdHuman | 相对时间 |
anchor | 锚点 ID,对应 id="{{ anchor }}" |
parent | 父评论 ID |
children | 子回复数组 |
9.1 提交表单
评论提交走公开接口 POST /api/comments/public,不需要登录与 CSRF:
html
{{#if post.allowComment}}
<form class="comment-form" data-cid="{{ post.cid }}">
<h4>发表评论</h4>
<input type="text" name="author" placeholder="昵称 *" required maxlength="60">
<input type="email" name="mail" placeholder="邮箱 *" required>
<input type="url" name="url" placeholder="网站(选填)">
<textarea name="text" rows="5" placeholder="说点什么…" required maxlength="10000"></textarea>
<button type="submit">提交评论</button>
<p class="form-msg"></p>
</form>
{{else}}
<p>本文已关闭评论。</p>
{{/if}}
<script>
(function () {
const form = document.querySelector('.comment-form');
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = form.querySelector('.form-msg');
const btn = form.querySelector('button');
const fd = Object.fromEntries(new FormData(form));
fd.cid = form.dataset.cid;
msg.className = 'form-msg';
msg.textContent = '提交中…';
btn.disabled = true;
try {
const r = await fetch('/api/comments/public', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fd),
});
const j = await r.json();
if (!r.ok || j.error) {
msg.className = 'form-msg err';
msg.textContent = j.error || '提交失败';
btn.disabled = false;
return;
}
msg.className = 'form-msg ok';
msg.textContent = j.message || '评论成功';
form.reset();
setTimeout(() => location.reload(), 700);
} catch (err) {
msg.className = 'form-msg err';
msg.textContent = '网络错误:' + err.message;
btn.disabled = false;
}
});
})();
</script>需要支持回复时,在每条评论下加一个按钮,把父评论 ID 存入表单的 data-parent 与隐藏字段:
html
<button type="button" class="reply-btn" data-coid="{{ coid }}">回复</button>
<input type="hidden" name="parent" value="">js
document.querySelectorAll('.reply-btn').forEach((b) => {
b.addEventListener('click', () => {
form.dataset.parent = b.dataset.coid;
form.querySelector('input[name=parent]').value = b.dataset.coid;
form.scrollIntoView({ behavior: 'smooth' });
});
});提交时把 parent 一并带上(Object.fromEntries(new FormData(form)) 已自动包含隐藏字段)。
10. 侧边栏常用片段
html
<!-- 站点信息 -->
<section class="widget">
<h3>关于</h3>
<p>{{ site.description }}</p>
{{#if site.author}}<p class="meta">作者:{{ site.author }}</p>{{/if}}
</section>
<!-- 分类 -->
<section class="widget">
<h3>分类</h3>
<ul>
{{#each $this.categories()}}
<li><a href="{{ permalink }}">{{ name }}</a><span class="count">{{ count }}</span></li>
{{/each}}
</ul>
</section>
<!-- 标签云 -->
<section class="widget">
<h3>标签</h3>
<div class="tagcloud">
{{#each $this.tags()}}
<a href="{{ permalink }}" class="tagpill">{{ name }} <em>{{ count }}</em></a>
{{/each}}
</div>
</section>
<!-- 归档树 -->
<section class="widget">
<h3>归档</h3>
{{#each $this.archiveTree()}}
<h4>{{ year }} 年{{#if month}} {{ month }} 月{{/if}} <em>{{ count }}</em></h4>
<ul>{{#each posts}}<li><a href="{{ permalink }}">{{ title }}</a></li>{{/each}}</ul>
{{/each}}
</section>
<!-- 最近评论 -->
<section class="widget">
<h3>最近评论</h3>
{{#each $this.recentComments(5)}}
<div class="rc">
<strong>{{ author }}</strong>
<a href="{{ permalink }}#{{ anchor }}">{{ excerptText }}</a>
<span>{{ createdHuman }}</span>
</div>
{{else}}
<p>暂无评论。</p>
{{/each}}
</section>
<!-- 独立页面 -->
<section class="widget">
<h3>页面</h3>
<ul>{{#each $this.pages()}}<li><a href="{{ permalink }}">{{ title }}</a></li>{{/each}}</ul>
</section>
<!-- 统计 -->
<section class="widget">
<h3>统计</h3>
<p>共 {{ options.postCount || 0 }} 篇文章</p>
</section>语法提示:
$this.categories()这种「方法调用作为表达式」在{{#each }}中可用;但在普通{{ }}中若需拿到返回值,请先赋值:html{{= cats = $this.categories() }} {{#each cats}}...{{/each}}
11. 主题配置
在 theme.json 的 config 中声明配置项,后台会自动渲染成表单。
11.1 支持的类型
type | 渲染为 | 补充字段 |
|---|---|---|
text | 单行文本框 | — |
textarea | 多行文本框 | — |
checkbox | 单个复选框 | — |
select | 下拉选择 | options: [{ value, label }] |
radio | 单选按钮组 | options: [{ value, label }] |
完整示例:
json
"config": [
{ "name": "accent", "label": "主题色", "type": "text", "default": "#2f6feb" },
{
"name": "layout",
"label": "布局",
"type": "select",
"default": "two",
"options": [
{ "value": "one", "label": "单栏" },
{ "value": "two", "label": "双栏" },
{ "value": "three", "label": "三栏" }
]
},
{ "name": "showSidebar", "label": "显示侧边栏", "type": "select", "default": "1",
"options": [{ "value": "1", "label": "显示" }, { "value": "0", "label": "隐藏" }] },
{ "name": "showExcerpt", "label": "首页显示摘要", "type": "select", "default": "1",
"options": [{ "value": "1", "label": "显示摘要" }, { "value": "0", "label": "显示全文" }] },
{ "name": "footerText", "label": "页脚附加文案", "type": "text", "default": "" },
{ "name": "icp", "label": "备案号", "type": "text", "default": "" },
{ "name": "analyticsCode", "label": "统计代码", "type": "textarea", "default": "" }
]11.2 在模板中读取
html
<style>
:root { --accent: {{ themeConfig.accent }}; }
</style>
<body class="layout-{{ themeConfig.layout }}">
{{#if themeConfig.showSidebar}}<aside>…</aside>{{/if}}
{{#if themeConfig.icp}}
<p class="icp">{{ themeConfig.icp }}</p>
{{/if}}
{{#if themeConfig.showExcerpt}}
<div>{{ post.excerptText }}</div>
{{else}}
<div>{{ post.content }}</div>
{{/if}}
</body>注意:
select与radio的值都是字符串,"1"与1不等价。判断请写{{#if themeConfig.showSidebar}}(非空即为真),不要写{{#if themeConfig.showSidebar == 1}}。统计代码这类需要原样注入的字段,必须用三花括号:
html{{{ themeConfig.analyticsCode }}}仅在你能确保内容来源可信时这样做。
11.3 配置分组与静态资源 CDN(可选)
config 字段较多时,可用顶层 configGroups 把它们分组显示在「主题设置」页(每组一个折叠区):
json
"configGroups": [
{ "key": "basic", "label": "基础外观" },
{ "key": "footer", "label": "页脚与导航" }
],
"config": [
{ "name": "themeMode", "label": "明暗模式", "type": "select", "default": "auto", "group": "basic" },
{ "name": "footerText", "label": "页脚文案", "type": "text", "default": "", "group": "footer" }
]可选地,在配置里声明一个 CDN 域名项(如 assetCdn),主题函数据此把 JS/CSS/图片改从对象存储/CDN 加载;留空则走本站。zm-riven 主题即内置此项与配套上传脚本。
12. 安全与转义
12.1 默认转义
{{ }} 始终进行 HTML 转义(含 < > & " ')。这是默认且强制的。
只在输出已确认安全的 HTML 时使用 {{{ }}}:
html
{{{ post.content }}} 正文,已过插件过滤器
{{{ site.title }}} 若标题允许富文本(默认不允许,保持 {{ }})12.2 不要输出敏感字段
| 字段 | 风险 |
|---|---|
comment.mail | 访客邮箱,属个人隐私 |
user.authSecret | 账户安全密钥 |
options 中的敏感项 | 视配置而定 |
绝不要把 comment.mail 输出到页面或 data-* 属性中。
12.3 内置安全处理
内核已替你处理:
- Markdown 中的链接与图片 URL 经
safeUrl校验,拒绝javascript:等危险协议 - 列表页、归档页、搜索结果、RSS/Atom 输出不会包含草稿、私密、定时未到文章的正文
- 页面引用用户生成内容时保持转义
12.4 用户输入的 URL
若要输出访客填写的 comment.url,先自行校验协议:
html
{{#if url}}
{{#if url}}{{/if}}
{{/if}}更稳妥的做法是干脆不输出访客 URL,或在服务端过滤后再渲染。
13. 模板编辑器与可编辑类型
后台「主题 → 模板」可直接编辑主题文件。
允许编辑(模板与静态资源):
.html .htm .css .txt .md .svg 无扩展名文件
禁止写入(安全闸门):
.js .mjs .cjs .json .node .exe .sh .php
原因:主题的 functions.js 会被 Node.js require 执行。若模板编辑器允许写入 .js,任何具备后台权限的人都能写入任意代码并立即执行——等价于远程代码执行。因此内核在接口层直接拒绝。
这意味着你不能在后台新建
functions.js。首次创建主题函数需要在服务器上放置文件,或通过「上传/复制」方式引入。这是刻意的设计取舍。
所有模板路径均经路径归一化与前缀校验,禁止越出主题目录。
14. 排错清单
| 现象 | 排查方向 |
|---|---|
| 前台 500,提示「模板不存在」 | theme.json 的 templates 中缺少该逻辑名,或 file 指向的文件不存在 |
| 页面样式全乱 | {{> _style }} 之类的片段没被引入;或 CSS 写在未生成的标签里 |
| 变量输出为空 | 变量名拼写错误;或该变量仅在特定页面存在(如 category 只在分类页) |
| 循环不输出内容 | {{#each}} 的表达式必须能取到数组;先 {{= list = ... }} 赋值再循环 |
| HTML 被转义显示 | 该用 {{ }} 却在输出 HTML,改用 {{{ }}} |
| 分页链接失效 | 硬编码了 /page/2。改用 $this.paginate(...) / $this.pageUrlOf(...) |
侧边栏 $this.categories() 报错 | 在 {{ }} 中不能直接调用方法返回值,先赋值给变量 |
| 模板改动不生效 | 确认改的是当前启用主题的文件;theme.json 中的 file 是否指向它 |
| 评论时间显示为 13 位数字 | 用了 created 而非 createdDate / createdIso |
| 主题启用后报错 | functions.js 有语法错误,用 node --check functions.js 验证 |
| 分类页样式与首页不同 | category 逻辑名指向了独立文件,需为其编写模板或复用 index.html |
15. 完整示例:单栏写作主题
以下是一个可直接使用的极简写作主题,核心是排版与阅读体验。
themes/zm-writing/theme.json
json
{
"name": "示例写作主题",
"author": "ZMBlog",
"version": "1.0.0",
"description": "单栏写作主题,正文优先,专注阅读",
"templates": [
{ "name": "index", "file": "index.html", "desc": "首页 / 列表" },
{ "name": "post", "file": "post.html", "desc": "文章" },
{ "name": "page", "file": "post.html", "desc": "独立页面" },
{ "name": "archive", "file": "index.html", "desc": "归档" },
{ "name": "category", "file": "index.html", "desc": "分类" },
{ "name": "tag", "file": "index.html", "desc": "标签" },
{ "name": "search", "file": "index.html", "desc": "搜索" },
{ "name": "password", "file": "password.html", "desc": "密码验证" },
{ "name": "404", "file": "404.html", "desc": "404" }
],
"config": [
{ "name": "accent", "label": "强调色", "type": "text", "default": "#111827" },
{ "name": "showToc", "label": "显示文章目录", "type": "select", "default": "1",
"options": [{ "value": "1", "label": "显示" }, { "value": "0", "label": "隐藏" }] },
{ "name": "footerText", "label": "页脚文案", "type": "text", "default": "" }
]
}themes/zm-writing/functions.js
js
'use strict';
module.exports = {
paginate: function (page, pageCount, baseExpr, values) {
if (!pageCount || pageCount <= 1) return '';
const expr = baseExpr || 'index.php';
const vals = values || {};
const link = (p) => `<a href="${this.pageUrlOf(expr, vals, p)}">${p}</a>`;
let html = '<nav class="pager">';
html += page > 1
? `<a href="${this.pageUrlOf(expr, vals, page - 1)}">← 新</a>`
: '<span class="off">← 新</span>';
html += `<span class="cur">${page} / ${pageCount}</span>`;
html += page < pageCount
? `<a href="${this.pageUrlOf(expr, vals, page + 1)}">旧 →</a>`
: '<span class="off">旧 →</span>';
return html + '</nav>';
},
};themes/zm-writing/_style.html
html
<style>
:root { --accent: {{ themeConfig.accent }}; }
* { box-sizing: border-box; }
body {
margin: 0;
font: 17px/1.85 -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
color: #1f2328;
background: #fff;
}
.wrap { max-width: 720px; margin: 0 auto; padding: 0 20px; }
header.site { border-bottom: 1px solid #eaecef; padding: 28px 0; margin-bottom: 36px; }
header.site h1 { margin: 0; font-size: 22px; }
header.site h1 a { color: var(--accent); text-decoration: none; }
header.site p { margin: 6px 0 0; color: #6b7280; font-size: 14px; }
nav.menu a { margin-right: 16px; font-size: 14px; color: #4b5563; text-decoration: none; }
nav.menu a:hover { color: var(--accent); }
.entry { margin-bottom: 56px; }
.entry h2 { font-size: 24px; line-height: 1.4; margin: 0 0 10px; }
.entry h2 a { color: inherit; text-decoration: none; }
.entry h2 a:hover { color: var(--accent); }
.meta { color: #8b949e; font-size: 13px; margin-bottom: 18px; display: flex; gap: 14px; flex-wrap: wrap; }
.meta a { color: #8b949e; text-decoration: none; }
.excerpt { color: #374151; }
.entry .content h2 { font-size: 20px; margin-top: 36px; }
.entry .content h3 { font-size: 17px; margin-top: 28px; }
.entry .content pre {
background: #f6f8fa; padding: 16px; border-radius: 8px; overflow-x: auto; font-size: 14px;
}
.entry .content code { background: #f6f8fa; padding: 2px 5px; border-radius: 4px; font-size: 14px; }
.entry .content pre code { background: none; padding: 0; }
.entry .content blockquote {
margin: 20px 0; padding: 4px 18px; border-left: 3px solid var(--accent); color: #6b7280;
}
.entry .content img { max-width: 100%; border-radius: 6px; }
.entry .content table { border-collapse: collapse; width: 100%; }
.entry .content th, .entry .content td { border: 1px solid #eaecef; padding: 8px 12px; }
.nav-post { display: flex; justify-content: space-between; margin: 32px 0 56px; font-size: 14px; }
.nav-post a { color: #4b5563; text-decoration: none; max-width: 45%; }
.comments { border-top: 1px solid #eaecef; padding-top: 32px; margin-top: 48px; }
.comment { margin-bottom: 18px; }
.comment .c-head { font-size: 13px; color: #8b949e; margin-bottom: 4px; }
.comment .c-body { font-size: 15px; }
.c-children { margin-left: 22px; border-left: 2px solid #f0f2f5; padding-left: 14px; }
.comment-form input, .comment-form textarea {
width: 100%; padding: 9px 12px; border: 1px solid #d8dce1; border-radius: 6px;
font: inherit; font-size: 15px; margin-bottom: 10px;
}
.comment-form button {
background: var(--accent); color: #fff; border: 0; padding: 9px 22px;
border-radius: 6px; font: inherit; cursor: pointer;
}
.pager { display: flex; justify-content: space-between; margin: 40px 0; font-size: 15px; }
.pager a, .pager span { color: #4b5563; text-decoration: none; }
.pager .off { color: #c9ced6; }
footer.site {
border-top: 1px solid #eaecef; margin-top: 56px; padding: 24px 0;
color: #8b949e; font-size: 13px;
}
</style>themes/zm-writing/_header.html
html
<header class="site">
<div class="wrap">
<h1><a href="/">{{ site.title }}</a></h1>
{{#if site.description}}<p>{{ site.description }}</p>{{/if}}
<nav class="menu" style="margin-top:14px">
<a href="/">首页</a>
<a href="/archive/">归档</a>
<a href="/feed">RSS</a>
</nav>
</div>
</header>themes/zm-writing/index.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{#if title}}{{ title }} - {{/if}}{{ site.title }}</title>
<meta name="description" content="{{ site.description }}">
<link rel="alternate" type="application/rss+xml" title="{{ site.title }}" href="/feed">
{{> _style }}
</head>
<body>
<div class="wrap">
{{> _header }}
{{#each posts}}
<article class="entry">
<h2><a href="{{ permalink }}">{{ title }}</a></h2>
<div class="meta">
<time datetime="{{ createdIso }}">{{ createdDate }}</time>
{{#if categories}}<span>{{#each categories}}<a href="{{ permalink }}">{{ name }}</a>{{/each}}</span>{{/if}}
<span>{{ readingMinutes }} 分钟阅读</span>
</div>
<div class="excerpt">{{{ excerptText }}}</div>
</article>
{{else}}
<p style="color:#8b949e">还没有文章。</p>
{{/each}}
{{{ $this.paginate(page, pageCount, 'index.php', {}) }}}
</div>
<footer class="site"><div class="wrap">
<p>© {{ site.title }}{{#if themeConfig.footerText}} · {{ themeConfig.footerText }}{{/if}}</p>
{{#if themeConfig.icp}}<p>{{ themeConfig.icp }}</p>{{/if}}
</div></footer>
</body>
</html>themes/zm-writing/post.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ post.title }} - {{ site.title }}</title>
<meta name="description" content="{{ post.excerptText }}">
<link rel="canonical" href="{{ site.url }}{{ post.permalink }}">
{{> _style }}
</head>
<body>
<div class="wrap">
{{> _header }}
<article class="entry">
<h2>{{ post.title }}</h2>
<div class="meta">
<time datetime="{{ post.createdIso }}">{{ post.createdDate }} {{ post.createdTime }}</time>
{{#if post.categories}}<span>{{#each post.categories}}<a href="{{ permalink }}">{{ name }}</a>{{/each}}</span>{{/if}}
{{#if post.tags}}<span>{{#each post.tags}}<a href="{{ permalink }}">#{{ name }}</a>{{/each}}</span>{{/if}}
<span>{{ post.readingMinutes }} 分钟阅读 · {{ post.wordCount }} 字</span>
</div>
{{#if post.password}}
<p style="padding:14px;background:#fff8e6;border-radius:6px">本文受密码保护,请在下方输入访问密码。</p>
{{else}}
<div class="content">{{{ post.content }}}</div>
{{/if}}
<div class="nav-post">
{{#if post.prev}}<a href="{{ post.prev.permalink }}">← {{ post.prev.title }}</a>{{else}}<span></span>{{/if}}
{{#if post.next}}<a href="{{ post.next.permalink }}">{{ post.next.title }} →</a>{{else}}<span></span>{{/if}}
</div>
</article>
{{> _comments }}
</div>
<footer class="site"><div class="wrap"><p>© {{ site.title }}</p></div></footer>
</body>
</html>themes/zm-writing/_comments.html
html
<section class="comments">
<h3 style="font-size:17px">{{ $this.commentCount(commentTotal) }}</h3>
{{#each comments}}
<div class="comment" id="{{ anchor }}">
<div class="c-head"><strong>{{ author }}</strong> · <time>{{ createdHuman }}</time></div>
<div class="c-body">{{ text }}</div>
{{#if children}}
<div class="c-children">
{{#each children}}
<div class="comment" id="{{ anchor }}">
<div class="c-head"><strong>{{ author }}</strong> · <time>{{ createdHuman }}</time></div>
<div class="c-body">{{ text }}</div>
</div>
{{/each}}
</div>
{{/if}}
</div>
{{else}}
<p style="color:#8b949e;font-size:14px">还没有评论。</p>
{{/each}}
{{#if post.allowComment}}
<form class="comment-form" style="margin-top:28px">
<input type="text" name="author" placeholder="昵称 *" required maxlength="60">
<input type="email" name="mail" placeholder="邮箱 *" required>
<input type="url" name="url" placeholder="网站(选填)">
<textarea name="text" rows="4" placeholder="说点什么…" required maxlength="10000"></textarea>
<input type="hidden" name="parent" value="">
<button type="submit">提交评论</button>
<p class="form-msg"></p>
</form>
{{/if}}
</section>
<script>
(function () {
const form = document.querySelector('.comment-form');
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = form.querySelector('.form-msg');
const btn = form.querySelector('button');
const fd = Object.fromEntries(new FormData(form));
fd.cid = '{{ post.cid }}';
msg.textContent = '提交中…';
btn.disabled = true;
fetch('/api/comments/public', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fd),
}).then((r) => r.json()).then((j) => {
if (j.error) { msg.textContent = j.error; btn.disabled = false; return; }
msg.textContent = j.message || '评论成功';
form.reset();
setTimeout(() => location.reload(), 700);
}).catch((err) => {
msg.textContent = '网络错误:' + err.message;
btn.disabled = false;
});
});
})();
</script>themes/zm-writing/password.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>需要访问密码 - {{ site.title }}</title>
{{> _style }}
</head>
<body>
<div class="wrap">
{{> _header }}
<div class="entry">
<h2>{{ post.title }}</h2>
<p style="color:#6b7280">本文受密码保护,请输入访问密码后查看内容。</p>
<form class="pw-form" data-cid="{{ post.cid }}">
<input type="password" name="pass" placeholder="访问密码" required>
<button type="submit">查看文章</button>
<p class="form-msg"></p>
</form>
</div>
</div>
<script>
(function () {
const form = document.querySelector('.pw-form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = form.querySelector('.form-msg');
// 通过 POST 提交密码,服务端校验后在签名 Cookie 里记录解锁状态;
// 不要再把密码拼进 URL(?pass=),会被浏览器历史/日志记录。
const r = await fetch('/api/posts/' + form.dataset.cid + '/unlock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: form.querySelector('input[name=pass]').value }),
});
const j = await r.json();
if (!r.ok || j.error) { msg.textContent = j.error || '验证失败'; return; }
location.reload();
});
})();
</script>
</body>
</html>密码模板的数据里包含
post.cid(文章 ID),解锁接口为POST /api/posts/:cid/unlock, 请求体{"password":"..."};成功后服务端下发签名的zmblog_unlockCookie, 刷新页面即可阅读。无需额外处理 CSRF(该接口匿名可用,仅按 IP 限流防爆破)。
themes/zm-writing/404.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>页面不存在 - {{ site.title }}</title>
{{> _style }}
</head>
<body>
<div class="wrap">
{{> _header }}
<div class="entry" style="text-align:center;padding:60px 0">
<h2 style="font-size:56px;margin:0;color:#eaecef">404</h2>
<p style="color:#6b7280">页面不存在,或内容已被移除。</p>
<p><a href="/">返回首页 →</a></p>
</div>
</div>
</body>
</html>启用方式:把目录放到 themes/zm-writing/,后台「主题」页启用 zm-writing。