主题接口:Hugo 式约定与整页回退
Go 的 html/template 没有模板继承,只有定义块和调用块。这篇讲在这个前提下怎么落出一套够用的主题约定,以及为什么缺失模板要整页回退而不是渲染空白。
主题系统要同时满足两件互相拉扯的事:作者写起来要足够自由(否则没人写),写坏了不能让站点出事(否则没人敢装)。
这篇讲 Lumo 在这两件事上各自做了什么取舍。
起点是 Go 的一个缺失
Go 的 html/template 没有模板继承。它只有「定义块」和「调用块」:
{{ define "x" }}...{{ end }}
{{ template "x" . }}
Hugo 那种 baseof.html + {{ block "main" }} 的写法,底层也是这两件事拼出来的。所以「Hugo 式约定」在 Lumo 里落成了这样:页面模板调用骨架,骨架回调 main。
{{ template "layouts/base.html" . }}
{{ define "main" }}
...
{{ end }}
看着有点绕,但它有个实际好处:谁调用谁是显式的。 一个页面模板通读一遍就知道它套了哪层骨架、填了哪个块,不需要在文件之间跳着找一个隐式约定。
选择 html/template 而不是 text/template 是安全决定。html/template 做上下文相关的自动转义——同一个值在 HTML 文本里、在属性里、在 <script> 里、在 URL 里的转义规则不同,它按位置判断。这是模板引擎该管的事,不该让每个主题作者自己记得。
上下文是服务端拼好的
九个内容路由各有固定的上下文:.Site、.Post、.Posts、.Pagination、.Categories、.Tags、.Theme.Settings、.Description、.Canonical 等等。
模板只消费服务端渲染好的内容,不在模板里做加工。只读数据一律走 Finder:
{{ .Find.Posts.Recent 5 }}
{{ .Find.Categories.Tree }}
{{ .Find.Menus.Get "primary" }}
Finder 命名空间是刻意收窄的:主题能查什么,由核心决定,不能自己拼查询。这样「主题能做什么」是一个可以读完的清单,而不是「看它能拿到什么 SQL 权限」。
缺模板要整页回退
必需模板只有四个:
index.html post.html page.html 404.html
另外十一个(分类、标签、归档、搜索、作者,登录、注册、找回密码、重置密码、账户页、我的收藏)都是可选的。
缺一个就整页回退到内置主题——不是局部回退,不是渲染一个空白页,不是报 404。
这是被「主题作者最正常的处境」逼出来的选择。一个人开始写主题时,绝不会先把十五个模板一次写全;他一定是从首页和文章页开始,剩下的一边用一边补。如果缺模板意味着站点有页面会挂,那他就得在补全之前一直提心吊胆,而这段时间恰好是他最需要看到成果的时候。
整页回退让「只做了两个模板的主题」是一个可以正常上线的主题,只是有些页面长得不像它。这个降级是可见的、不致命的,而且完全可以不修。
自由的边界:模板里不许加工 HTML
一条硬规则:除了文章正文 .Post.Content,任何用户可控的值都直接输出,绝不 safeHTML。
理由很直接。正文之外的那些字段——标题、昵称、评论内容、菜单名、分类描述——都来自访客或权限较低的用户。在模板里给它们套一层 safeHTML,等于把注入面直接交到访客手上,而且是在主题这一层,核心的净化逻辑完全看不见。
正文是唯一的例外,因为它本来就允许写 HTML,而且它已经在保存时按权限净化过:没有 content:unsafe_html 权限的角色,正文在入库前就按允许列表处理(保留排版、表格、代码块、远程 iframe,去掉脚本与事件属性)。把这条权限授予编辑,等于允许他对管理员执行脚本——所以默认只有管理员和超级管理员有。
主题设置复用站点设置那一套
主题可以带一份 settings.yaml 声明自己的设置项。后台用与站点设置完全相同的表单引擎渲染它:JSON Schema 子集加 x-widget,十八种控件、条件显隐、重复条目加拖动排序。
这不是偷懒,是刻意的。主题设置和站点设置在站长眼里是同一类东西——「一堆选项,改完保存」——做成两套界面只会让人多学一遍。同理,声明式而非手写 JSON 表单,因为字段名会在结构体、表单声明和公开白名单里各出现一次,声明式让前两处的对应关系由编译器看着:写错一个键是编译错误,不是「打开那一页才发现」。
一个踩过的细节:条目型字段(首页模块、侧栏小组件、社交链接)必须显式给出 x-order。YAML 经 map 解析后键的顺序会丢,不给顺序的话,「模块类型」那个选择器会掉到表单最下面,看起来像个 bug。
主题包的安全
上传的主题包按 zip 安全解压规则处理:拒绝路径穿越(../)、拒绝符号链接、限制条目数量与解压后总大小。规则与插件包共用同一份实现,因为「解压一个别人给的压缩包」是一个问题,不该有两套答案。
内置主题会落一份到磁盘
内置主题「墨」经 go:embed 嵌在二进制里,但启动时会解压一份到 data/themes/ink,此后以磁盘那份为准。
原因简单:主题系统的全部意义在于改模板要足够便宜。只在内存里渲染的话,站长想改一行文案就得重编译二进制。落盘之后,改模板、点「重新加载」、即时生效;改坏了点「恢复出厂」;但不能删除——它是所有主题的回退目标。
这条也让「墨」成了主题作者最好的参考材料。照着它的结构写自己的主题是可以的,学习写法不是复制作品;但直接改它再闭源出售不行,因为它是 Lumo 的一部分,以 AGPL-3.0-or-later 授权,改它是衍生作品。
自由的那一半在授权里
上面全在讲限制,但真正决定生态能不能起来的是另一半,而它在代码之外。
AGPL 的传染性会让「主题算不算衍生作品」说不清楚,而没有答案的授权问题等于没有生态。所以项目附带了一份接口例外条款:仅通过主题接口与 Lumo 交互的独立作品,不因该交互被视为衍生作品,你可以用任何条款开发和销售它,包括专有条款。
再往下就是主题与插件那一页讲的三条红线。这里只补一句设计上的态度:这套例外是不可撤销的。 已经发布的版本永远带着它——生态建设最需要的东西不是自由度,是自由度不会在下个版本被收回的确定性。
评论
还没有评论,来说两句。