给代码块加上可填写的示例参数:Chaos主题的code_vars和其他一些近期更新

• 本文约6676字,阅读大致需要14分钟 • Development

我平时会写不少技术文档。其中的示范代码,特别是涉及命令行操作的部分,往往包含一些需要临时填写并在后文重复使用的参数,读者不能直接照搬例子去运行,而是需要先做一些修改才能使用。

前几天在写生成SSHFP和TLSA记录这篇作弊条的时候,我顺手给本站使用的Chaos主题加上了一个可交互的示例参数功能,并把之前合并BIND 9子域等几篇旧文也一并改了过来。

从减少重复手工操作、避免忙中出错的角度来说,许多时候(尤其是对于「作弊条」这类题材的内容),比较理想的文档形式应该是一个可以填写、填完就能直接复制的模板。这是因为在这些操作里,变量(或者说需要读者填写的内容)通常是主机名、IP地址,或是本地某条命令的输出结果(例如公钥的hash值),而其余参数则应与原文保持严格一致,且同一组参数往往会贯穿全文。

例如在TLSA和SSHFP那篇里,域名example.net、邮件服务器mx1、SSH主机beastie,以及前一步刚用OpenSSL算出来的公钥SHA-256和SHA-512散列值,会在后面的命令行和BIND区域文件模板里反复出现。假如完全靠手工在各个代码块之间来回查找替换,不仅繁琐,而且漏改或是贴错一段,还很容易导致一些其他问题。

在一些文档系统的DSL中,有一种用<var>标签标记的可编辑占位符:在渲染出来的页面上填入新值之后,整篇文档里的示例命令就会跟着一起变,点复制按钮拿到的也是替换好的命令。我认为这种交互非常适合类似作弊条这样的内容,于是用Antigravity给Chaos主题也做了一套类似的实现。这里记录一下它在实际文章中的写法,以及在迭代过程中考虑的一些细节。

在文章中如何书写

在文档里表达「这里需要替换成你自己的值」,常见的传统做法不外乎两种:

  1. 写占位符。例如<DOMAIN>、{{ZONE}}或者$YOUR_IP。这种做法的优点是一眼就能看出哪些地方需要改;缺点也很明显:它们往往不是合法的语法,不仅会让构建时的代码高亮错乱,在RSS阅读器或者没有开启JavaScript的环境里看起来也会显得支离破碎。
  2. 写具体但无害的示例字面量。例如RFC 2606保留的example.net、RFC 5737保留的192.0.2.1。这样代码块本身就是合法、完整的示例,但代价是读者需要自己分辨哪些字面量只是示例,并逐一替换。

我这次实现的code_vars对第二种方法做了少许改进:在Markdown正文和代码块里,依然直接书写合法的默认值字面量,而不引入新的自定义Markdown扩展,但在文章开头的YAML Front Matter里声明哪些字面量属于可替换的参数。

以生成SSHFP和TLSA记录为例,Front Matter中的声明大致如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
code_vars:
  - name: domain
    default: "example.net"
    desc: "域名"
  - name: mx
    default: "mx1"
    desc: "邮件服务器主机名"
  - name: sha256
    default: "71d5e809<SHA256值>"
    desc: "公钥SHA-256散列值(3 1 1)"

基本上,每一个变量有一个名字(name)、一个用作示例的默认值(default)和一个简短的说明(desc)。这里之所以用列表而不是直接把变量名或默认值当作YAML映射的键,是因为Hugo在解析.Params时会把所有映射键统一转成小写;如果默认值里包含大写字母(例如<SHA256值>或大小写敏感的路径),放进键名就会丢失原有的大小写,因此统一放在default字段的值里最为稳妥。

在Markdown正文的代码块中不需要特殊的标记,只需按原样正常书写示例值即可,例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
```bash
openssl x509 -in mx1.example.net.cer -noout -pubkey \
  | openssl pkey -pubin -outform DER \
  | openssl dgst -sha256 -r \
  | awk '{print "# SHA256: " $1}'
# SHA256: 71d5e809<SHA256值>
```

```text
_25._tcp.mx1.example.net.    IN TLSA 3 1 1 71d5e809<SHA256值>
_465._tcp.mx1.example.net.   IN TLSA 3 1 1 71d5e809<SHA256值>
```

如此,模板渲染出来的内容和之前一样,仍然是一个普通的纯文本示例。对于RSS/Atom订阅,或在无JavaScript的环境里打开页面时,读者看到的将是一篇干干净净的普通技术文章。在浏览器中阅读时,按需加载的code-vars.js(未声明code_vars的普通文章不会加载)会自动在第一个命中参数的代码块上方插入一个可折叠的「示例参数」面板(这样做是为了避免这个表单状的方块影响阅读),同时将代码块中匹配到的示例值包裹为<var class="code-var">。

在桌面浏览器中,读者可以在展开的面板表单中填写参数,或是在阅读过程中点击这些变量所在的区域原地进行编辑(通过contenteditable实现;移动端及触摸屏设备通过(pointer: coarse)媒体查询关闭了原地编辑,以免手指滑动代码或点按复制时误弹出软键盘)。例如,在第一步算出公钥的SHA-256值之后将其填入,后面BIND zone的内容也就自动拼好了。由于代码块右上角的「复制」按钮直接读取DOM节点的textContent,复制出来的自然就是替换后的最新命令。

在实际把旧文章改造成code_vars的过程中,我还遇到了两类需要额外处理的情况:

  1. 排除不需要替换的代码块({code_vars="false"}):一篇文章里并不是所有代码块都是给读者复制执行的。例如TLSA文章开头用来解释语法结构的格式说明,或者合并BIND 9子域第二节里贴出的当年实际踩坑时的delv报错与dig输出——这些属于故障现场的真实记录。给代码块围栏加上Chroma属性```text {code_vars="false"}即可跳过该代码块。
  2. 正文行内代码的同步(code_vars_inline):最初的实现中,这些替换只作用于独立代码块,而不动正文。不过后来在复查BIND 9那篇文章时,我发现正文段落里写着「最后执行rndc reconfig……再执行rndc thaw sjc.example.net解冻」这样的句子。如果读者前面一直照着替换好的代码块操作,看到这一步顺手把正文里的命令粘进终端,拿到的却还是默认的sjc.example.net。因此后来我把正文里的行内<code>也一并纳入了同步与原地编辑(但跳过了h1至h6标题,以免标题文字变化后与侧边栏目录不一致)。当然,如果某篇文章的正文本身就在讨论这些默认字面量(例如主题示例站里的说明文档),可以在Front Matter里加上code_vars_inline: false让正文保持原样。

其他一些细节

功能本身的原理并不复杂,不过要让「查找替换」尽量不添乱,还是有不少边界情况需要处理。

词法边界、DOM跨节点匹配与两种常见的正则表达式变形

首先是不能做无脑的子串替换。Hugo在构建阶段已经用Chroma把代码块渲染成了带语法高亮的HTML,一句mx1.example.net或者一段配置在DOM里可能已经被拆成了好几个相邻的<span>文本节点;但如果只看纯文本直接替换,又会破坏原有的语法高亮标签。因此,code-vars.js在初始化时会先用TreeWalker遍历容器内的文本节点,拼出完整文本并记录每个文本节点的起止偏移,完成匹配后再按倒序用DOM Range包裹成<var>元素。<var>本身继承外层的语法高亮字色(color: inherit),且左右内边距设为0,保证等宽字体下的列对齐。

为了避免误伤,匹配器按默认值的长度从长到短优先匹配(防止example.net抢先吃掉sjc.example.net的后半截),并检查前后的词法边界:例如,默认IP 192.0.2.1不会误匹配192.0.2.10的前缀,密钥名dhcpd-key也不会命中my-dhcpd-key。

更有趣的一个问题出现在把合并BIND 9子域改成code_vars的时候。那篇文章在Front Matter里定义了父域名sjc.example.net和用空格分隔的5个子域列表guest iot tiot t ut,它们在Shell循环里长这样:

rndc freeze sjc.example.net
for sub in guest iot tiot t ut; do
    rndc freeze ${sub}.sjc.example.net
done

但是到了文章末尾用awk剔除委派记录的命令里,同样两个参数需要写成正则表达式:

awk '!(($1 ~ /^(guest|iot|tiot|t|ut)\.sjc\.example\.net\.$/) && \
        ($4 == "NS" || $4 == "DS"))' sjc.example.net.raw > sjc.example.net.merged

如果只按示例值去精确匹配,那么上面这条awk里的guest|iot|tiot|t|ut和sjc\.example\.net就全都漏掉了,读者复制出去还是得手工改正则表达式,而恰恰这种拼在一起的正则表达式最容易改漏反斜杠或者多写一个竖线。但反过来,如果为了迁就这一条命令而在Front Matter里专门再定义一套「正则版参数」,或者在Markdown里发明某种模板过滤器语法,无论写起来还是读起来都会很麻烦。

观察了一下自己平时写的脚本,这类变形基本就是两种固定的对应关系:

  1. 点号的正则转义(. ↔ \.):域名或IP地址在普通命令和配置文件里直接写.,而在awk、sed、grep的正则表达式里,为了避免.匹配任意字符,正规写法都会转义成\.。因此当某个参数的默认值包含.(且本身尚未包含\.)时,code-vars.js会自动派生出一个将.全部替换为\.的匹配模式。
  2. 空格分隔列表与正则分支(a b c ↔ a|b|c):在Shell的for循环或数组里,一组并列的名字通常以空格分隔;而放进扩展正则表达式时,同一组名字自然就变成了以|连接的分支结构。因此当默认值是由空白字符分隔的多个词条(匹配/^\S+(?:\s+\S+)+$/)时,脚本也会自动派生出以|连接的形式。

这两种派生都是双向的(toCanonical与fromCanonical):无论读者是在顶部的参数面板里把sjc.example.net改成home.example.org、把子域列表改成lab dev,还是直接在awk那一行原地编辑正则表达式,脚本都会在内部先正规化成原值,再按各处各自的形态重新格式化输出:普通命令里依然是.和空格,awk正则里则自动变成home\.example\.org和lab|dev。如此,在绝大多数情况下,作者不需要特地去多写一步(我考虑过在Front Matter中注入JavaScript来完成复杂转写这种可能性,但感觉自己的病暂时还有救,于是果断放弃了这个想法),而读者也不需要为此特别去单独修改正则表达式。

URL参数同步,以及对外部链接的醒目提示

另一个在迭代中花了些心思的细节是URL状态同步。

当读者修改了任意参数并确认输入(失焦、按回车、按Escape或点击重置)时,code-vars.js会通过history.replaceState把非默认的参数写入浏览器地址栏的查询字符串,例如?domain=example.org&mx=mail(刻意放在确认输入时才更新,而不是每敲一个键就调用一次replaceState,是因为某些浏览器对replaceState有调用频率限制,例如Safari限制在30秒内不超过100次)。

把填写的内容放进URL主要有两个实际用途:一是方便自己把填好自家服务器信息的作弊条直接存成书签;二是和同事、朋友讨论问题时,可以先帮对方把域名或主机名填好,再直接把链接发过去,对方打开就能直接复制命令。例如,点开/posts/2026/11/tlsa-sshfp/?domain=freebsd.org&mx=mx1,页面里的域名和MX主机名就已经预先填好了。

不过,允许通过URL查询参数改写页面上的代码块又会带来一个潜在的安全问题:收到别人发来的链接的人,未必会意识到URL后面挂着一串查询参数。

虽然所有参数值都是通过textContent写入DOM的,避免了直接注入HTML或执行恶意脚本,但如果有人构造了一个带有特定IP、域名甚至恶意命令参数的链接发给别人(尤其是链接末尾还可以带上#客户端配置这样的锚点,打开后直接滚到页面中段的代码块),不知情的读者很可能会以为屏幕上显示的命令就是文章里写的内容,并直接复制到终端去执行。

因此,我把「读者自己动手改的参数」和「从URL带进来的参数」在外观上做了区分:

  • 默认状态与自行修改:未修改时,代码块里的变量只在浅灰底色上带一条不太抢眼的1px墨色虚线下划线,避免干扰正常阅读或被误认为超链接;读者自己在页面上修改后,因为改的人心里有数,下划线只是简单地由虚线变为细实线,代码块左上角挂一个低调的灰色「参数已修改」小标签,方便随时点「重置」恢复默认。
  • 参数来自URL链接(is-from-url):如果初始参数来自地址栏的查询字符串,界面会切换到主题的警告色(浅色模式下采用传统和色中的「蘇芳」#9E3D3F配「薄桜」底色,深色模式下为「退紅」#D69090):
    1. 平时默认折叠收起的「示例参数」面板会自动展开,并在顶部显眼位置用警告色横幅提示:「以下参数来自链接,非作者原文。」;
    2. 面板中由URL填入的输入框边框,以及正文和代码块中所有对应位置的下划线,都会染上同样的警告色;
    3. 每一个受影响的代码块左上角都会挂上警告色的「参数来自链接」标签。这样即使对方通过带#锚点的链接直接跳到了文章中段的某个代码块,也能在复制前看到提醒;点击这个标签则会滚回参数面板,方便查看或重置。一旦读者自己动手编辑了某个字段,该字段的fromUrl标记就会清除,退回普通的「参数已修改」状态。

顺便记一笔这里踩到的一个Hugo(准确地说是Go标准库html/template)的小坑:最初在模板里把本地化字符串传给JavaScript时,我随手把属性名写成了data-url-badge="{{ T "codeVarsUrlBadge" }}"。结果页面渲染出来,徽章上的中文变成了%e5%8f%82%e6%95%b0%e6%9d%a5%e8%87%aa%e9%93%be%e6%8e%a5。这是因为html/template的自动转义逻辑只要看到属性名里带有url这个词(匹配data-*-url-*等模式),就会把属性值当作URL处理并对非ASCII字符做百分号编码。最后采用的解法是把属性名改成data-link-badge,同时在主题的静态检查脚本tools/check.py中也加了一条规则,防止以后再往名字带url的属性里塞翻译文本,以免再次误伤。

Chaos主题近期的其他改动

除了code_vars之外,我近期也给Chaos主题修补和完善了一些细节:

  • 系列文章(series)目录与时间轴索引:以前主题虽然支持series分类,但同属一个系列的文章之间没有直接跳转的导航。这次在系列文章末尾加上了按写作时间正序排列的系列目录卡片,同时重做了/series/索引页,按各系列最近一次更新的时间排序并以时间轴呈现(窄屏下与归档页共用按年份分组的紧凑布局)。趁这个机会,我也把站内早年的几组连载整理归入了各自的系列,并把入口放进了顶部导航栏。
  • XML时间格式统一与等宽时间戳:此前RSS订阅源和sitemap.xml里的时间戳直接使用了构建机器的本地时区偏移,在不同时区的机器上构建,或是跨夏令时切换时,同一个时刻会写出不同的字符串,影响按字符串顺序比较时间。现在它们与Atom订阅源一样统一输出UTC时间,配合XSLT样式表在浏览器端自动转换成读者所在的本地时区显示(悬停仍可查看UTC时间)。此外,页面上所有的<time>元素统一改用了预加载的等宽字体子集,让归档页和列表页的日期列自然对齐;并增加了showTime选项(默认auto),对于Front Matter里只写日期不写具体时分的站点自动隐藏具体的小时、分钟。
  • 一些打印样式与深色模式细节修复:在1.3.0给打印稿末尾加上文章永久链接二维码(构建时生成静态小PNG,仅在打印媒体查询下按需加载)的基础上,修复了深色模式下直接打印文章时Mermaid流程图仍保留深色SVG节点填充、印在纸上变成黑块的问题。另外还修掉了深色模式下打开文章时Remark42评论框在初始化阶段先闪一下浅色背景的毛病,并恢复了文章正文的两端对齐(去掉了在WebKit下容易导致整段提前断行的text-wrap: pretty,并在写本文时顺手修掉了标题意外继承两端对齐的问题)。

上述改动目前已经发表到公开的chaos-theme仓库。