Skip to content

05 · 模板 DSL:从词法到渲染

模板不是 string.replace()。它是一门带变量、过滤器、条件、循环、赋值、成员访问和异步解析的小语言。

一、语言能力概览

一个完整模板可以写成:

liquid
{% 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:30compileTemplate() 是公开入口:

text
input text
  → strip URL text fragment
  → build RenderContext
  → render(text, context)
      → tokenize
      → parse tokens to AST
      → validate
      → render AST
  → if hasDeferredVariables
      → processVariables()
  → final string

RenderContext 携带:

  • variables:页面内容模型;
  • currentUrl:markdown/fragment_link 等过滤器的基准;
  • tabId:浏览器 selector resolver 所需;
  • applyFilterDirect:过滤器调用入口;
  • asyncResolver:selector 等环境相关变量;
  • 可选 custom filters 与局部 scopes。

三、Tokenizer:字符流如何变 token

tokenizer.ts 用显式状态机,而不是单个巨型正则。主要 mode:

text
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('|')

liquid
{{ title | replace:"a|b":"c" }}
{{ selector:.card[data-name="a:b"] | first }}
{{ value | map:item => item.name }}

Tokenizer 必须知道当前是否在引号、CSS selector、括号或转义序列中,才能判断 |:, 是语法还是参数内容。

位置追踪

TokenizerState 保存 indexlinecolumn。每个 token 和 error 都带位置信息,为设置页的模板验证提供可读错误,而不是只抛 Unexpected token

CSS selector 特判

tokenizeCssSelector() 处理 selector 中的方括号、引号、冒号与转义。selector: 前缀后面的内容语法密度很高,必须与普通 identifier 分开消费。

四、Parser:token 如何变 AST

parser.ts 定义两类节点。

模板节点

text
TextNode
VariableNode(expression)
IfNode(branches, elseBody)
ForNode(variable, iterable, body)
SetNode(name, value)

表达式节点

text
LiteralExpression
IdentifierExpression
BinaryExpression
UnaryExpression
FilterExpression
GroupExpression
MemberExpression

AST 的价值是把语法理解与执行分离。设置页可以只 parse + validate,真正剪藏时再 render;测试也能分别验证 token、AST 和结果。

五、表达式优先级

Parser 用递归下降分层表达优先级:

text
nullish (??)
  → or
    → and
      → not
        → comparison (== != > < >= <= contains)
          → postfix/member
            → filter
              → primary/group

每一层只消费自己认识的运算符,并把下一层作为 operand。这样:

liquid
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) 会:

  • 收集预设变量;
  • 理解 forset 引入的局部变量;
  • 遍历表达式中的 identifier/member;
  • 允许 selector/schema/prompt 等特殊前缀;
  • 对未知变量计算 Levenshtein distance,给出相近拼写建议。

这意味着 {{ tite }} 可以提示可能想写 title

过滤器校验

validateFilters(ast) 收集所有 FilterExpression:

  • 检查名称是否在 validFilterNames
  • 对参数调用 filter metadata validator;
  • 为未知过滤器寻找相似名称;
  • 用 example 告诉用户正确格式。

校验与执行共用 metadata,避免文档规则和运行规则漂移。

八、Renderer:AST 如何执行

renderer.ts:103render() 组合 tokenize、parse、validate 与 renderAST()。RenderState 维护:

text
context       全局 variables / URL / resolver
scopes        局部作用域栈
errors        运行时错误
hasDeferredVariables
pendingTrimRight

节点分发:

text
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,再逐个求参数。一个很贴近用户习惯的规则:

liquid
{{ value | callout:info }}

info identifier 在变量表中不存在,会被当作字符串参数 "info",而不是空值。否则所有裸参数都必须加引号,模板会非常啰嗦。

参数求值完成后,renderer 直接调用 applyFilterDirect(value, name, paramString, url),避免把 AST 又拼回字符串再重新拆分。

十二、延迟变量的两阶段设计

普通变量、schema 与 selector(有 resolver 时)可在 AST 阶段求值。prompt 与部分特殊变量需要后处理。

当 renderer 无法立即处理时:

  1. 保留 {{...}} 占位文本;
  2. 标记 hasDeferredVariables = true
  3. compiler 才运行 processVariables() 正则扫描;
  4. 分派到 selector/schema/prompt/model/simple processor。

若没有延迟变量,compiler 直接返回,避免对整份正文再做一次 regex 扫描。

十三、错误策略:部分输出优先

render() 返回:

ts
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 行,新增语法必须同步测试三层。

十六、新增语法时要改什么

以新增运算符为例:

  1. TokenType 增加 token;
  2. tokenizer 识别字符/关键字;
  3. parser 选择正确优先级层并生成 AST;
  4. Expression union 增加或复用节点;
  5. renderer 实现求值;
  6. formatter/validation 遍历新表达式;
  7. tokenizer、parser、renderer tests 都加正常与错误用例;
  8. 文档说明 precedence 与 coercion。

DSL 维护的纪律就是:语法、语义、诊断必须一起演进。

十七、本章检查点

  • 能画出 text → token → AST → output → post-process;
  • 能解释为什么 tokenizer 不能用简单 split;
  • 能说出模板节点与表达式节点的区别;
  • 能解释作用域、truthiness 和裸过滤器参数;
  • 能说明延迟变量为何采用两阶段处理;
  • 能列出新增语法需修改的层。

基于 Obsidian Web Clipper 1.7.1 源码快照的独立学习笔记