05 · 模板 DSL:从词法到渲染
模板不是
string.replace()。它是一门带变量、过滤器、条件、循环、赋值、成员访问和异步解析的小语言。
一、语言能力概览
一个完整模板可以写成:
{% set authors = schema:author ?? author %}
# {{ title | trim }}
{% if authors %}
Authors:
{% for item in authors %}
- {{ item.name ?? item }}
{% endfor %}
{% endif %}
{{ content }}语法家族:
| 类别 | 示例 |
|---|---|
| 插值 | {{ title }} |
| 过滤器 | {{ title | safe_name }} |
| 条件 | {% if site == "GitHub" %} |
| 分支 | {% elseif ... %} / {% else %} |
| 循环 | {% for item in schema:author %} |
| 赋值 | `{% set x = title |
| 运算 | and, or, not, contains, ?? |
| 成员 | item.name, items[0] |
| trim | {{- value -}} 等空白控制 |
二、编译总管
template-compiler.ts:30 的 compileTemplate() 是公开入口:
input text
→ strip URL text fragment
→ build RenderContext
→ render(text, context)
→ tokenize
→ parse tokens to AST
→ validate
→ render AST
→ if hasDeferredVariables
→ processVariables()
→ final stringRenderContext 携带:
variables:页面内容模型;currentUrl:markdown/fragment_link 等过滤器的基准;tabId:浏览器 selector resolver 所需;applyFilterDirect:过滤器调用入口;asyncResolver:selector 等环境相关变量;- 可选 custom filters 与局部 scopes。
三、Tokenizer:字符流如何变 token
tokenizer.ts 用显式状态机,而不是单个巨型正则。主要 mode:
text 普通模板文本
variable {{ ... }}
tag {% ... %}TokenType 覆盖:
- 文本、变量/tag 开闭标记;
- identifier、string、number、boolean、null;
- pipe、comma、dot、bracket、parenthesis;
- 比较、逻辑、nullish 运算符;
- if/elseif/else/endif、for/in/endfor、set;
- trim left/right 标记;
- EOF 与错误位置信息。
为什么必须逐字符
以下输入会击穿简单的 split('|'):
{{ title | replace:"a|b":"c" }}
{{ selector:.card[data-name="a:b"] | first }}
{{ value | map:item => item.name }}Tokenizer 必须知道当前是否在引号、CSS selector、括号或转义序列中,才能判断 |、:、, 是语法还是参数内容。
位置追踪
TokenizerState 保存 index、line、column。每个 token 和 error 都带位置信息,为设置页的模板验证提供可读错误,而不是只抛 Unexpected token。
CSS selector 特判
tokenizeCssSelector() 处理 selector 中的方括号、引号、冒号与转义。selector: 前缀后面的内容语法密度很高,必须与普通 identifier 分开消费。
四、Parser:token 如何变 AST
parser.ts 定义两类节点。
模板节点
TextNode
VariableNode(expression)
IfNode(branches, elseBody)
ForNode(variable, iterable, body)
SetNode(name, value)表达式节点
LiteralExpression
IdentifierExpression
BinaryExpression
UnaryExpression
FilterExpression
GroupExpression
MemberExpressionAST 的价值是把语法理解与执行分离。设置页可以只 parse + validate,真正剪藏时再 render;测试也能分别验证 token、AST 和结果。
五、表达式优先级
Parser 用递归下降分层表达优先级:
nullish (??)
→ or
→ and
→ not
→ comparison (== != > < >= <= contains)
→ postfix/member
→ filter
→ primary/group每一层只消费自己认识的运算符,并把下一层作为 operand。这样:
a or b and not c会按 a or (b and (not c)) 解释,而不是从左到右盲算。
六、结构标签的解析
if
parseIfStatement() 读取条件与 body,随后循环吸收 elseif,可选 else,最后要求 endif。每个 branch 保存自己的 condition 和 body。
for
parseForStatement() 读取循环变量、in 与 iterable expression。循环体由 parseBody() 解析,直到 endfor。
set
parseSetStatement() 读取 identifier、等号和 expression,把结果写入当前 render scope。
parseBody(stopKeywords) 是三类结构复用的关键:遇到 stop keyword 不消费,让上层结构决定如何收尾。
七、静态校验不是可有可无
Parser 后半部提供两类 validation。
变量校验
validateVariables(ast) 会:
- 收集预设变量;
- 理解
for与set引入的局部变量; - 遍历表达式中的 identifier/member;
- 允许 selector/schema/prompt 等特殊前缀;
- 对未知变量计算 Levenshtein distance,给出相近拼写建议。
这意味着 {{ tite }} 可以提示可能想写 title。
过滤器校验
validateFilters(ast) 收集所有 FilterExpression:
- 检查名称是否在
validFilterNames; - 对参数调用 filter metadata validator;
- 为未知过滤器寻找相似名称;
- 用 example 告诉用户正确格式。
校验与执行共用 metadata,避免文档规则和运行规则漂移。
八、Renderer:AST 如何执行
renderer.ts:103 的 render() 组合 tokenize、parse、validate 与 renderAST()。RenderState 维护:
context 全局 variables / URL / resolver
scopes 局部作用域栈
errors 运行时错误
hasDeferredVariables
pendingTrimRight节点分发:
text → 原样输出
variable → evaluate expression → valueToString
if → 找第一个 truthy branch
for → iterable 标准化 → 每项 push scope
set → 当前 scope 写值九、作用域与循环
renderFor() 需要处理的不只是数组:
- 数组逐项遍历;
- JSON 字符串尝试 parse 成数组/对象;
- 对象可转 entries;
- 空值跳过;
- 每轮创建局部 scope,避免变量泄漏;
- 可提供 loop 上下文(索引等,依实现约定)。
set 写入当前 scope,外层变量不会被意外全局污染。作用域栈让嵌套 for/if 能正确遮蔽同名变量。
十、Truthiness 语义
模板语言不能直接照搬 JavaScript,否则字符串 "false"、空数组和 JSON 值会产生意外。isTruthy() 统一处理:
undefined/null/false为假;- 空字符串或特定空表示为假;
- 数组按长度;
- 其他值按模板语义判断。
contains 对字符串使用不区分大小写的包含;数组元素比较时也对字符串做 case-insensitive 相等。
十一、过滤器执行
FilterExpression 先求 base value,再逐个求参数。一个很贴近用户习惯的规则:
{{ value | callout:info }}若 info identifier 在变量表中不存在,会被当作字符串参数 "info",而不是空值。否则所有裸参数都必须加引号,模板会非常啰嗦。
参数求值完成后,renderer 直接调用 applyFilterDirect(value, name, paramString, url),避免把 AST 又拼回字符串再重新拆分。
十二、延迟变量的两阶段设计
普通变量、schema 与 selector(有 resolver 时)可在 AST 阶段求值。prompt 与部分特殊变量需要后处理。
当 renderer 无法立即处理时:
- 保留
{{...}}占位文本; - 标记
hasDeferredVariables = true; - compiler 才运行
processVariables()正则扫描; - 分派到 selector/schema/prompt/model/simple processor。
若没有延迟变量,compiler 直接返回,避免对整份正文再做一次 regex 扫描。
十三、错误策略:部分输出优先
render() 返回:
interface RenderResult {
output: string
errors: RenderError[]
hasDeferredVariables: boolean
}compiler 打印错误但返回 output。对编辑器验证来说 errors 应突出展示;对实际剪藏来说,部分结果往往比完全失败更有价值。
这也意味着调用者若用于自动化,应主动检查/记录错误,而不是假设 Promise resolve 就代表模板完全正确。
十四、空白控制
Markdown 对空行敏感。trim 标记会分别影响:
trimLeft:删除前一个输出末尾空白;trimRight:设置 pending flag,删除下一个节点开头空白。
Renderer 在 append node output 时统一实现,而不是让每种节点各自裁剪。这样 if/for 不会因为控制标签残留多余空行。
十五、为什么没有直接采用 Liquid/Nunjucks
从源码可以推断自研 DSL 的收益:
- selector/schema/prompt 是领域变量;
- 过滤器行为与 Obsidian Markdown/YAML 强绑定;
- 需要在浏览器扩展和 Node 中运行;
- 需要部分输出、拼写建议和模板 UI 校验;
- CSP 环境不适合依赖动态代码生成;
- 可以保持可控的语法子集。
代价是 tokenizer/parser/renderer 接近 4,000 行,新增语法必须同步测试三层。
十六、新增语法时要改什么
以新增运算符为例:
TokenType增加 token;- tokenizer 识别字符/关键字;
- parser 选择正确优先级层并生成 AST;
- Expression union 增加或复用节点;
- renderer 实现求值;
- formatter/validation 遍历新表达式;
- tokenizer、parser、renderer tests 都加正常与错误用例;
- 文档说明 precedence 与 coercion。
DSL 维护的纪律就是:语法、语义、诊断必须一起演进。
十七、本章检查点
- 能画出 text → token → AST → output → post-process;
- 能解释为什么 tokenizer 不能用简单 split;
- 能说出模板节点与表达式节点的区别;
- 能解释作用域、truthiness 和裸过滤器参数;
- 能说明延迟变量为何采用两阶段处理;
- 能列出新增语法需修改的层。