Spintax 用于 PHP
在 WordPress 插件里渲染 spintax 的那个引擎,现在单独发布了:spintax/core,MIT 许可,运行时零依赖,PHP 8.0+。没有 WordPress,没有框架,也不对你的模板放在哪里做任何假设。
这是什么——以及大多数 PHP spintax 库不是什么
在 Packagist 搜 spintax,你会找到一堆 {a|b|c} 解析器。那是替换原语,而且确实够用——直到你的文案需要自洽的那一刻为止。
“we support %n% languages” 这样的句子需要名词与数字一致;出现两次的短语需要仍然是同一个短语;只给付费客户看的段落需要一个条件,而不是抛硬币。spintax/core 把这些作为一等构件提供,而不是留给你在控制器里拼字符串。
| 构件 | 典型的 PHP spintax 库 | spintax/core |
|---|---|---|
枚举 {a|b|c} | 有 | 有 |
排列 [<config>a|b|c] | 很少 | 有,含分隔符配置 |
变量 %name% | 偶尔 | 三个作用域,优先级有定义 |
条件 {?VAR?…} | 无 | 有 |
| 复数一致 | 无 | 有,按 locale |
Include #include | 无 | 有,带环与深度防护 |
| 后处理 | 无 | 空格、大小写、URL 防护 |
安装
composer require spintax/core
PHP 8.0 或更新,ext-mbstring,仅此而已。没有框架集成要配置,没有 service provider 要注册。
快速上手
use Spintax\Core\Render\Pipeline;
$pipeline = new Pipeline();
echo $pipeline->render(
'{Welcome to|Meet} %product% — supports %n% {plural %n%: language|languages}.',
['product' => 'Acme', 'n' => '3'],
);
// → "Meet Acme — supports 3 languages."
// → "Welcome to Acme — supports 3 languages." (the enumeration is a fresh
// pick on every call; see Determinism below to pin it)
一个对象,一次调用。引擎是基于实例的,不是静态门面——因为那些只配置一次的东西(#include 从哪取模板、每个模板都能看到哪些全局变量、随机源)属于流水线的生命周期,而不是每次渲染。
API
日常使用时,Pipeline 就是全部公开接口。Parser、Validator、Plurals 和 Conditionals 暴露在它下面,给只需要单个工序而非整条流程的工具用。
| 调用 | 作用 |
|---|---|
Pipeline::render($raw, $runtime_vars, $context, $locale, $post_process) | 把整条流水线跑成一个字符串。宽容——坏掉的构件以可见的方式降级,而不是抛异常。 |
Validator::validate($template, $known_slugs, $global_var_names, $locale) | 返回 ['errors' => […], 'warnings' => […]],每条带消息、行、列。有效 ⇔ 没有 error。 |
Plurals::apply($text, $lang, $options) | 单独的复数工序,而且默认严格:坏掉的构件会抛异常。传 ['lenient' => true] 改为降级。Pipeline 已替你选了宽容模式,这就是 render() 从不因模板内容抛异常的原因。 |
Conditionals::apply($template, $variables) | 单独的条件工序。 |
Parser::* | 各个独立工序:注释、指令提取、变量展开、枚举、排列、后处理、include。 |
确定性
没有 seed 参数。你注入的是随机源——同一个思路,往下挪了一层:
$deterministic = new Pipeline(new Parser(fn(int $min, int $max) => $min));
任何形如 fn(int $min, int $max): int 的可调用对象都行:带种子的 PRNG、固定选择、从测试夹具录制的序列——引擎不需要知道是哪一种。
处处相同的语法
语法参考逐字适用——WordPress 插件、JavaScript 包和演练场说的是同一门语言。
| 构件 | 示例 | 含义 |
|---|---|---|
| 枚举 | {a|b|c} | 选一个(可嵌套) |
| 排列 | [<minsize=2;sep=", ">a|b|c] | 取 N 个、打乱、拼接 |
| 变量 | %name% | 代入一个值 |
| 局部 set | #set %v% = value | 宏——每次引用重新抽取 |
| 局部 def | #def %v% = value | 每次渲染解析一次,处处一致 |
| 条件 | {?VAR?then|else} | 按值分支 |
| 复数 | {plural %n%: one|few|many} | 按 locale 做一致 |
| Include | #include "slug" | 嵌入另一个模板 |
| 注释 | /# … #/ | 渲染前剔除 |
复数一致——大家最晚才发现的那个
写成枚举的 %n% {товар|товара|товаров} 会随机挑一种形式:对 1 是错的,对 3 是错的,对 5 也是错的——而你恰好预览到的那一次看起来没问题。复数构件改为由数字决定形式:
{plural %n%: товар|товара|товаров}
ru、uk、be、sr、hr、bs 是三种形式,英语式 locale 是两种。分桶规则和边界情况见复数指南。
知道哪些没有实现。波兰语、捷克语、斯洛伐克语、斯洛文尼亚语和保加利亚语各有自己的复数体系,引擎一个都没有实现——但也不会拒绝它们。pl 模板会按英语的两形式规则分桶,而那不是波兰语语法。这个包自己的 README 原话就是这么说的;这些 locale 的输出请当作未经验证。
Include:I/O 归你,安全归引擎
取模板是 I/O——一次查询、一次文件读取、一次缓存查找——所以它属于你的应用。取回之外的一切留在引擎里:
$pipeline = new Pipeline(
source: fn(string $slug): ?string => $repository->findBySlug($slug),
);
递归、环检测、深度上限和总扇出预算无论你的 resolver 做什么都会被强制执行。循环 include 会解析成空,而不是死循环。这个切分是刻意的:再天真的宿主也不该能用一个模板把自己吊死。
刻意不做的部分
缓存、模板存储、设置——以及输出净化。render() 返回未净化的文本。输出 HTML 的宿主必须自己对结果跑净化器;WordPress 插件用的是 wp_kses_post(),你的应用应该用你的上下文所要求的那个。
这一点值得明说而不是埋起来:一个替你猜测转义规则的引擎,最后一定是你要与之搏斗的引擎。它渲染文本,然后交还给你。
一致性是被强制的,不是被打算的
一套共享 golden corpus(语言无关的用例:(template, context, locale, seed) → expected)就是各实现之间的契约。这个包的测试套件就是那套语料——从 JavaScript 仓库检出而不是拷贝一份,因为副本会漂移,而会漂移的契约不是契约。
它双向生效:一条用例只有在它约束的引擎都已满足时才能进入语料;一个引擎改动只有在语料仍然通过时才能合入。用例跑的是发布的 Pipeline,不是测试本地的复制品。
不纳入闸门的是随机选择。带 seed 的渲染在同一引擎内可复现;跨引擎的随机序列一致是有意的非目标。两边都给你合法的结果,只是不保证同一个。
两个包刻意不同的地方
同一门语言,同样的语义,不同的人机工程。@spintax/core 提供函数式门面、可复用 AST 和工具层(analyze、neutralize);PHP 包提供流水线加各工序原语,还多一个会抛异常的严格复数模式——放在编辑器后面很有用。诊断的形态也不同:两边都报带行列的消息,但只有 JavaScript 那边附带稳定、纳入一致性闸门的 code——演练场正是靠它把诊断映射到翻译后的文案。PHP 校验器没有 code 字段,想按具体问题分支的宿主要按结构匹配,而不是按标识符。两个引擎之间被闸门锁住的是结论——模板是否有效——不是解释它的措辞。
插件是 GPL,这个包为什么能是 MIT
这是一次抽取,不是重写,而且接缝在原代码里早已切好:插件的渲染器一侧是纯粹的工序编排器,另一侧是 WordPress 适配层,引擎文件里本来就没有任何 WordPress 引用。把它们搬出来近乎纯删除:一个常量守卫、几条 linter 注释,以及把一个 WordPress 的 JSON 助手换成标准库的。
这些代码的版权由同一位作者持有——这正是改授许可得以成立的原因。引擎是原创实现:GTW 是这个引擎兼容的语法,不是这里任何代码的祖先,而条件、复数和后处理层都是从零写的。MIT 与 GPL 兼容,所以 GPL 插件可以使用 MIT 包,反过来则不行。
一个引擎,多个表面
- OpenCart SEO ——固定依赖
spintax/core并解包进扩展。今天真实存在的下游消费者。 - WordPress 插件——同一份引擎代码,虽然它仍带着自己的副本而非依赖这个包。跨引擎语料已经堵上了依赖本要解决的漂移风险;合并是清理,不是修复。
- 你的应用——Laravel 任务、Symfony 命令、普通脚本。这个包对是哪一种没有意见。