主题开发指南
主题是什么
主题(template)负责前台「渲染长相」,只消费控制器下发的视图变量,不查数据库、不做数据加工 —— 数据属于控制器与服务的职责。
未安装或未启用主题时渲染自动回退到 default;切换主题不影响任何数据,卸载零成本。
主题禁止修改核心文件、引用其他主题的文件;硬编码 URL 与静态资源路径也是禁区(伪静态规则后台可改,硬编码必然失效)。
目录结构与 manifest
目录结构:themes/主题名/ 下放 manifest.json(必需)、view/(必需,模板文件)、static/(主题静态资源)、preview.png(市场展示缩略图,建议提供)。
manifest 必填:name、title、version、cms_version(如 >=8.0,缺了安装被拒)。主题名允许短横线;主题名一旦发布不要再改 —— 它同时是目录名、静态资源路径和授权绑定的包名。
config.json 用于声明主题可配置项与默认内容:items 是表单项声明(name/title/type/default),会被整理成键值表注入 $themeCfg;data 是结构化默认内容(如 hero、features、faq),注入 $themeData。颜色类配置须为 #RRGGBB(AA) 格式才会下发。目前后台只下发默认值、尚无保存覆盖能力,模板逻辑要能容忍配置项为空。
// themes/mt_official/manifest.json
{
"name": "mt_official",
"title": "企业官网主题",
"version": "1.0.0",
"cms_version": ">=8.0",
"author": "你的名字或公司",
"description": "深色首屏 + 栏目卡片 + 文章网格,全站响应式。",
"dependencies": []
}
// themes/mt_official/config.json(items → $themeCfg,data → $themeData)
{
"items": [
{ "name": "primary_color", "title": "主题主色", "type": "color", "default": "#2563eb" },
{ "name": "show_categories", "title": "首页显示栏目卡片", "type": "switch", "default": "1" }
],
"data": {
"hero": { "title": "数字化转型专家", "subtitle": "一站式解决方案" },
"features": [
{ "title": "快速部署", "desc": "十分钟上线" },
{ "title": "持续维护", "desc": "终身免费更新" }
]
}
}
模板文件命名(8 个必须补齐)
模板路径由当前启用主题动态拼出,命名规则:view/控制器小写/方法名.html。例如文章详情页对应 view/article/detail.html。
必须提供全部 8 个模板:layout/base.html(公共骨架)、index/index.html(首页)、article/list.html(栏目列表)、article/detail.html(文章详情)、page/detail.html(单页)、search/index.html(站内搜索)、message/form.html(在线留言)、error/404.html。
缺少任何一个模板,前台访问对应页面直接报错。建议从 default 主题的同名模板派生,保证契约正确。
公共变量(一):站点与页面元信息
$site —— 站点基础配置(键值对数组,所有页面可用)。常用键:site_name 站点名称、site_slogan 标语、site_keywords 站点关键词、site_description 站点描述、site_logo LOGO 地址、copyright 版权文字、icp 备案号。取不存在的键返回 null,渲染前做空值兜底。
$categories —— 导航栏目树(数组)。每个元素含 id 栏目 id、title 栏目名、slug URL 标识、children 子栏目数组(可能为空)。栏目链接一律用 {:cms_url("category", $cate)} 生成。
$friendLinks —— 友情链接列表,可能为空数组,页脚展示用。
$seo —— 当前页面的 SEO 三件套:title 页面标题、keywords 关键词、description 描述。由控制器按页面范围(首页/列表/详情/单页)组装好,模板只输出到 head,不要自己拼。
$canonical —— 本页规范地址(完整绝对 URL),输出到 link rel="canonical";页面未设置时为空,输出前判空。
$alternates —— 本页 hreflang 替代地址列表,每个元素含 code 语言代码、html BCP47 代码、dir URL 目录、url 完整地址;未启用多语言时为空数组,模板可无条件遍历输出 alternate 链接。
<!-- layout/base.html:<head> 里的标准写法(所有公共变量一次用全) -->
<title>{$seo.title}</title>
<meta name="keywords" content="{$seo.keywords}">
<meta name="description" content="{$seo.description}">
<link rel="canonical" href="{$canonical}">
<!-- hreflang:$alternates 未启用多语言时是空数组,可直接遍历 -->
{volist name="alternates" id="alt"}
<link rel="alternate" hreflang="{$alt.code}" href="{$alt.url}">
{/volist}
<!-- 站点信息:取键 + 空值兜底 -->
<a class="site-name" href="{:cms_url('home')}">{$site.site_name}</a>
{if condition="!empty($site.icp)"}<span>{$site.icp}</span>{/if}
<!-- 导航栏目树:递归渲染一级 + 二级 -->
<ul class="nav">
{volist name="categories" id="cate"}
<li>
<a href="{:cms_url('category', $cate)}">{$cate.title}</a>
{if condition="!empty($cate['children'])"}
<ul>
{volist name="cate.children" id="sub"}
<li><a href="{:cms_url('category', $sub)}">{$sub.title}</a></li>
{/volist}
</ul>
{/if}
</li>
{/volist}
</ul>
公共变量(二):主题配置与多语言
$themeCfg —— 主题 config.json 里 items 声明的配置项键值表:键为 item.name,值为 item.default(如 $themeCfg.primary_color 取主题主色)。颜色类值已校验为 #RRGGBB(AA) 格式。未声明的键不存在,取用前判空或给默认值。
$themeData —— 主题 config.json 里 data 声明的结构化默认内容(数组,如 hero 首屏文案、features 特性列表、faq 问答),供「介绍型主题」装完即有完整内容,模板按自己声明的 data 结构遍历。
$langs —— 启用的语言清单:每个元素含 code 语言代码、name 显示名、dir URL 目录(默认语言为空串,如英文站为 en)、is_default 是否默认语言。
$currentLang —— 当前语言:在 $langs 元素基础上追加 html(BCP47 代码,用于 html lang 属性,如 zh-CN)与 prefix(URL 前缀:默认语言为空串,其他语言为 /en 形式)。生成站内链接时要带上 $currentLang.prefix。
$langEnabled —— 多语言总开关(布尔值)。关闭时 $langs 只有一个元素、$currentLang.prefix 为空,语言切换器要先判断它再渲染。
<!-- layout/base.html:<html> 标签带上当前语言的 BCP47 与书写方向 -->
<html lang="{$currentLang.html}">
<!-- 主题主色:从 $themeCfg 取,缺省给兜底值,写进内联 style -->
<style>
:root { --primary: {neq name="$themeCfg.primary_color" value=""}{$themeCfg.primary_color}{else /}#2563eb{/neq}; }
</style>
<!-- $themeData:遍历主题默认内容(介绍型主题的开箱文案) -->
<section class="features">
{volist name="themeData.features" id="feat"}
<div class="feat"><h3>{$feat.title}</h3><p>{$feat.desc}</p></div>
{/volist}
</section>
<!-- 语言切换器:$langEnabled 关闭时整个不渲染;
$lang.dir 即该语言的 URL 前缀目录(默认语言为空串),拼根路径即该语言首页 -->
{if condition="$langEnabled"}
<ul class="lang-switch">
{volist name="langs" id="lang"}
<li>
<a href="/{$lang.dir}/" class="{eq name='lang.code' value='$currentLang.code'}on{/eq}">{$lang.name}</a>
</li>
{/volist}
</ul>
{/if}
<!-- 同页跨语言切换(文章/栏目/单页):直接用 $alternates 里现成的 url -->
{volist name="alternates" id="alt"}
<a href="{$alt.url}" hreflang="{$alt.code}">{$alt.code}</a>
{/volist}
页面独有变量:首页与列表页
首页 index/index.html —— $banners:轮播图列表,每个元素含 title 标题、link 跳转链接、image 图片地址;image 可能为空,需自行降级为纯文字横幅。$articles:最新文章列表。$jsonLd:站点实体 JSON-LD 字符串(未开启 GEO 或未配置根地址时为空串,判空后再输出 script 标签)。
栏目列表 article/list.html —— $category 当前栏目(含 id/title/slug 及栏目全部字段)、$articles 当前页文章、$total 总条数、$page 当前页码(从 1 起)、$size 每页条数;分页链接用 $total/$size 算总页数、按 $page 生成。
列表与首页里每个文章元素的常用字段:id、title 标题、slug、summary 摘要、author 作者、views 浏览量、published_at 发布时间(截取前 10 位即日期);链接统一用 {:cms_url("article", $vo)} 生成。
<!-- index/index.html:banner 无图降级为纯文字横幅 -->
{if condition="!empty($banners)"}
{volist name="banners" id="vo" length="1"}
<section class="banner">
<h2>{$vo.title|raw}</h2>
{if condition="$vo.image"}
<img src="{$vo.image}" alt="{$vo.title}">
{/if}
{if condition="$vo.link"}
<a href="{$vo.link}" target="_blank">了解更多 →</a>
{/if}
</section>
{/volist}
{/if}
<!-- 站点实体 JSON-LD:由控制器注入,判空后原样输出 -->
{if condition="$jsonLd"}<script type="application/ld+json">{$jsonLd|raw}</script>{/if}
<!-- article/list.html:文章列表 + 分页 -->
{volist name="articles" id="vo"}
<article class="item">
<h3><a href="{:cms_url('article', $vo)}">{$vo.title|raw}</a></h3>
<div class="meta">
<span>{$vo.published_at|substr=0,10}</span>
<span>{$vo.views} 次浏览</span>
{if condition="$vo.author"}<span>{$vo.author}</span>{/if}
</div>
<p>{$vo.summary|raw}</p>
</article>
{/volist}
<!-- 分页:用 $total / $size / $page 自算页数(简化示意) -->
{php}$pages = max(1, (int)ceil($total / max(1, $size)));{/php}
{if condition="$pages gt 1"}
<nav class="pager">
{for start="1" end="$pages" name="i"}
<a class="{eq name='i' value='$page'}on{/eq}"
href="{:cms_url('category', $category, $i)}">{$i}</a>
{/for}
</nav>
{/if}
页面独有变量:详情、单页、搜索与留言
文章详情 article/detail.html —— $article 当前文章,常用字段:title、summary、content 正文 HTML、author、source 来源、views、published_at、updated_at、category_name 所属栏目名;$geo 为 GEO 增强的完整结果数组。GEO 子项已展平为顶层变量直接用:$keyPoints 要点列表、$toc 目录树、$faqList 常见问答、$citation 引用块、$sources 数据来源、$jsonLd 页面结构化数据字符串。
单页 page/detail.html —— $page 当前单页(常用 title、content、updated_at),另有与文章详情完全同一套的 $geo/$keyPoints/$toc/$faqList/$citation/$sources/$jsonLd —— 两类详情页视图契约刻意一致,模板片段可以复用。
站内搜索 search/index.html —— $keyword 当前搜索词(可能为空串)、$articles 结果列表(字段同列表页文章)、$total、$page、$size。
在线留言 message/form.html —— $sent 是否提交成功(1 为成功)、$msg 错误提示文字(可能为空);表单提交到固定路由 /message/submit,不要写死完整域名。
404 error/404.html —— 无独有变量,只用公共变量。
<!-- article/detail.html:GEO 子项已展平到顶层,直接遍历,无需 $geo['xxx'] 取值 -->
{if condition="!empty($keyPoints)"}
<section class="key-points">
<h2>核心要点</h2>
<ul>{volist name="keyPoints" id="kp"}<li>{$kp|raw}</li>{/volist}</ul>
</section>
{/if}
{if condition="!empty($faqList)"}
<section class="faq">
<h2>常见问题</h2>
{volist name="faqList" id="qa"}
<div class="qa"><h3>{$qa.question|raw}</h3><p>{$qa.answer|raw}</p></div>
{/volist}
</section>
{/if}
<!-- 数据来源:生成式引擎的溯源依据 -->
{if condition="!empty($sources)"}
<ol class="sources">
{volist name="sources" id="src"}<li>{$src|raw}</li>{/volist}
</ol>
{/if}
<!-- 正文:content 是控制器产出的可信 HTML,单独用 raw;其余字段保持默认转义 -->
<article>
<h1>{$article.title}</h1>
<div class="body">{$article.content|raw}</div>
</article>
<!-- message/form.html:提交结果与错误提示都要判空 -->
{if condition="$sent"}<p class="ok">提交成功,我们会尽快回复。</p>{/if}
{if condition="$msg"}<p class="err">{$msg}</p>{/if}
<form method="post" action="/message/submit">
<input type="text" name="name" required>
<input type="text" name="contact" required>
<textarea name="content" required></textarea>
<button type="submit">提交留言</button>
</form>
变量使用总则
只能使用上述契约内的变量,不要假设更多;也不在模板里自行查库或拼接 JSON-LD 结构化数据(控制器统一注入,模板只输出)。
所有列表都可能为空、所有字符串字段都可能为空串:banner 无图、文章无摘要、栏目无子级、配置项缺失时都要正常降级,不报错、不塌版。
输出用户可控内容(标题、摘要、搜索词等)时使用模板默认转义;确需输出 HTML 的正文(content)单独用 raw,其余不要随手加 raw。
<!-- 正确:默认转义,标题/摘要/搜索词都安全 -->
<h3>{$vo.title}</h3>
<p>搜索「{$keyword}」的结果:</p>
<!-- 错误:对用户可控内容加 raw = 自开 XSS 口子 -->
<h3>{$vo.title|raw}</h3> <!-- 不要这样做 -->
<!-- 正确:仅对控制器产出的可信 HTML 用 raw -->
<div class="body">{$article.content|raw}</div>
<script type="application/ld+json">{$jsonLd|raw}</script>
常量、函数与埋点
静态资源:主题自己的 css/js/images 必须用 THEME_STATIC 常量引用(指向 /theme-static/主题名),不要写死相对路径;公共静态目录用 STATIC。
地址生成:用 cms_url() 函数生成内容、栏目、单页与首页地址,禁止硬编码 URL。
埋点(3 处必埋 + 1 处建议):layout/base.html 的 body 开头埋 {:hook("page_header")}、body 结束前埋 {:hook("page_footer")} —— 这两处不埋,所有插件的对应钩子在你的主题下都会静默失效;文章详情页侧栏建议埋 content_detail_sidebar(不埋只是带侧栏能力的插件不显示)。埋点参数结构不要改动,插件依赖它。
<!-- layout/base.html:常量与埋点的标准位置 -->
<!DOCTYPE html>
<html lang="{$currentLang.html}">
<head>
<link rel="stylesheet" href="__THEME_STATIC__/css/main.css">
<link rel="stylesheet" href="__STATIC__/css/common.css">
</head>
<body>
<!-- 必埋:body 开头,页面级插件的输出会拼在这里 -->
{:hook('page_header')}
<!-- 页面内容:cms_url 生成站内地址,第三参为页码(列表分页用) -->
<a href="{:cms_url('home')}">首页</a>
<a href="{:cms_url('page', ['slug' => 'about'])}">关于我们</a>
<!-- 建议埋:文章详情页侧栏,带当前文章上下文 -->
{:hook('content_detail_sidebar', ['article' => $article])}
<!-- 必埋:body 结束前 -->
{:hook('page_footer')}
</body>
</html>
主题不能做的事
不查数据库、不调用 Db、不直接 new 模型;需要的数据让核心在控制器里提供。
不在模板里拼 JSON-LD / 结构化数据;不写复杂业务判断与数据加工逻辑。
打包与上架要求与插件一致:manifest.json 在 zip 根、语义化版本、pricing 声明完整;建议准备 preview.png 作为市场展示图。上架后包内容冻结(市场私钥签名覆盖全部定价声明),要改内容就发新版本。