Skip to content

ZMBlog · 模板开发文档 ​

适用版本:ZMBlog v2.3.1  阅读对象:主题开发者、想自定义站点外观的站长


目录 ​

  1. 主题结构
  2. 快速上手:复制一套主题
  3. 模板引擎语法
  4. 可用变量
  5. 主题函数
  6. 内置 Widget 方法
  7. 文章对象字段全表
  8. 分页
  9. 评论
  10. 侧边栏常用片段
  11. 主题配置
  12. 安全与转义
  13. 模板编辑器与可编辑类型
  14. 排错清单
  15. 完整示例:单栏写作主题

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 令牌
$thisWidget 对象,可调用其全部方法
$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 原文
typepost(文章)或 page(独立页面)
statuspublish / 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
createdIsoISO 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格式化日期 / 时间
createdIsoISO 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_unlock Cookie, 刷新页面即可阅读。无需额外处理 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。


相关文档 ​

ZMBlog · MIT License