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 就是全部公开接口。ParserValidatorPluralsConditionals 暴露在它下面,给只需要单个工序而非整条流程的工具用。

调用作用
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%: товар|товара|товаров}

ruukbesrhrbs 是三种形式,英语式 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 和工具层(analyzeneutralize);PHP 包提供流水线加各工序原语,还多一个会抛异常的严格复数模式——放在编辑器后面很有用。诊断的形态也不同:两边都报带行列的消息,但只有 JavaScript 那边附带稳定、纳入一致性闸门的 code——演练场正是靠它把诊断映射到翻译后的文案。PHP 校验器没有 code 字段,想按具体问题分支的宿主要按结构匹配,而不是按标识符。两个引擎之间被闸门锁住的是结论——模板是否有效——不是解释它的措辞。

插件是 GPL,这个包为什么能是 MIT

这是一次抽取,不是重写,而且接缝在原代码里早已切好:插件的渲染器一侧是纯粹的工序编排器,另一侧是 WordPress 适配层,引擎文件里本来就没有任何 WordPress 引用。把它们搬出来近乎纯删除:一个常量守卫、几条 linter 注释,以及把一个 WordPress 的 JSON 助手换成标准库的。

这些代码的版权由同一位作者持有——这正是改授许可得以成立的原因。引擎是原创实现:GTW 是这个引擎兼容的语法,不是这里任何代码的祖先,而条件、复数和后处理层都是从零写的。MIT 与 GPL 兼容,所以 GPL 插件可以使用 MIT 包,反过来则不行。

一个引擎,多个表面

  • OpenCart SEO ——固定依赖 spintax/core 并解包进扩展。今天真实存在的下游消费者。
  • WordPress 插件——同一份引擎代码,虽然它仍带着自己的副本而非依赖这个包。跨引擎语料已经堵上了依赖本要解决的漂移风险;合并是清理,不是修复。
  • 你的应用——Laravel 任务、Symfony 命令、普通脚本。这个包对是哪一种没有意见。