复数一致:{plural <count>: form1|form2|form3}
中文名词没有复数变形——但你的模板面向的语言有。俄语(以及所有斯拉夫语)要求名词与前面的基数词一致:1 язык、2 языка、5 языков;英语要区分 1 language / 3 languages。选择取决于数字对 100 和 10 取模,11–14 还有例外。Spintax 是 spintax 家族里第一个把这做成一等原语的引擎。在此之前,每个编辑都在模板里重新发明这条规则并且必定在某处出错——或者干脆悄悄绕开一切带数字的句式。
语法
{plural <count>: form1|form2|form3}
字面前缀 {plural (末尾带一个空格)是与同义形状 {a|b|c} 的明确判别符。: 把数量槽和形式槽分开。形式之间用竖线分隔。
| Locale 家族 | 语言 | 形式数 |
|---|---|---|
| 东斯拉夫 | ru、uk、be |
3:one|few|many |
| BCS | sr、hr、bs |
3:one|few|many |
| 英语式(默认) | en、es、pt、de、it、fr、nl、sv、no、da、fi、… |
2:one|many |
塞尔维亚语、克罗地亚语和波斯尼亚语逐字复用东斯拉夫的分桶规则——1、21、101 取 one(11 除外),2–4、22–24 取 few(12–14 除外),其余包括零全部取 many。CLDR 给 BCS 的第三桶标的是 other 而非 many;位置上是同一个槽,所以按俄语形式数写的模板不改就能用。
阿拉伯语、威尔士语、希伯来语和拉脱维亚语的分桶结构不同,有意未实现。真实需求出现时按语言逐个落地。
示例
supports %LangCount% {plural %LangCount%: language|languages}
ships with %IntegrationCount% {plural %IntegrationCount%: integration|integrations}
поддерживает %LangCount% {plural %LangCount%: язык|языка|языков}
получите %BonusCount% {plural %BonusCount%: бонус|бонуса|бонусов}
processed in {plural %PayoutHours%: hour|hours}
завершить за {plural 30: день|дня|дней}
数量槽接受 %Var% 引用或字面整数。复数工序运行时,变量代换早已完成——所以无论编辑写的是哪种形式,助手在数量槽里只会看到整数字符串。
Locale 规则(俄语三桶)
俄语规则出了名地繁琐。完整表格:
数量 n | 桶 | 俄语示例 |
|---|---|---|
1, 21, 31, 41, …, 101, 121 | one | 1 язык, 21 язык |
2–4, 22–24, 32–34, … | few | 2 языка, 23 языка |
0, 5–20, 25–30, 35–40, …, 100, 111–114 | many | 0 языков, 11 языков, 25 языков |
11–14 的例外(按末位数字看本该像 one 和 few)正是变通法栽跟头的地方。引擎助手替每个编辑、每个计数、每个模板一次性堵上这个缺口。算法:
const abs = Math.abs(n);
const mod10 = abs % 10;
const mod100 = abs % 100;
if (mod10 === 1 && mod100 !== 11) return forms[0]; // one
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return forms[1]; // few
return forms[2]; // many
负数取 abs,与 CLDR 一致。零取 many 形式("0 языков"),因为那是俄语里语法正确的写法——不是因为零被特殊处理。
为什么用冒号(而不是 {plural %N%|forms})
早期草案写的是只用竖线的 {plural %LangCount%|язык|языка|языков}。两个结构性问题杀死了那种形式:
1. 助手变量的隐患。常见的预设宏:
#set %LangPlural% = {plural %LangCount%: язык|языка|языков}
如果构件在变量代换后长成 {12|язык|языка|языков},它和四选一的同义枚举无法区分——下一道工序会兴高采烈地随机挑一个。冒号形式让判别前缀穿过展开存活下来,复数工序才能安全地排在变量代换之后。
2. 字面整数。{30|день|дня|дней} 与同义 {a|b|c} 形状撞车——解析器分不出来。冒号形式让 {plural 30: день|дня|дней} 在结构上独一无二。
数字边界情况
数量槽严格解析。如果一个槽位可能悄悄表达与编辑预期不同的含义,整个构件解析成空串。
| 数量槽 | 结果 | 原因 |
|---|---|---|
12 | 选中的形式 | 普通整数 |
-3 | 3 的形式 | abs(),与 CLDR 一致 |
0 | 选中的形式(RU:many;EN:many) | 零是合语法的 |
12 | 12 的形式 | 空白被裁剪 |
| (空) | 整个构件 → 空 | 数量缺失 |
%MissingVar%(没代换成) | 整个构件 → 空 | 展开后不是数字 |
1,200 | 整个构件 → 空 | 逗号不是数字;parseInt 会撒谎返回 1 |
12abc / 08h | 整个构件 → 空 | 尾随非数字字符被拒绝 |
1.5 | 整个构件 → 空 | v1 只收整数 |
数量缺失时想抹掉整句(而不只是构件),用条件给整句把门:
{?HasLanguages?supports %LangCount% {plural %LangCount%: language|languages}|}
形式槽——不许嵌套 spintax 括号
形式必须是纯文本,不能含嵌套的 spintax 括号 { } [ ]。{plural 1: {a|b}|c} 会被拒绝,{plural 1: [<and>day|days]} 同样。校验器报错误;运行时宽容处理,把块降级成全角括号而不是抛异常(见下)。
形式里确实需要条件或随机内容时,先提升到变量——而且必须是 #def,不是 #set:
/# wrong: nested synonym in form #/
{plural 2: {integration|connector}|integrations}
/# also wrong: #set is a macro — the brackets come straight back #/
#set %Noun% = {integration|connector}
{plural 2: %Noun%|%Noun%s}
→ {plural 2: {integration|connector}|{integration|connector}s}
/# right: #def resolves once, so the form slot receives plain text #/
#def %Count% = 2
#def %Brand% = {Acme|Acme Cloud}
{plural %Count%: %Brand% integration|%Brand% integrations}
→ Acme Cloud integrations
这正是 #def 值得存在的区别。#set 在每处引用原样代入它的值,把同义词提升进 #set 只会把括号送回你想保持干净的槽位。#def 每次渲染抽取一次,交给形式槽的是解析完的文本。
注意被提升的值是什么:一个不变形的片段,在每种形式里都一样。这个模式只支持这种形状。不要提升名词再靠给变量拼后缀造它的形式——%Noun%а 对一个同义词成立,对下一个就是垃圾。词本身要变形时,把各形式完整写出来——形式槽就是干这个的。
HTML 标签(<em>、<a href="…">)和未解析的 %Var% 在形式文本里无害地存活——被禁止的只有结构性的 spintax 括号。
宽容运行时——坏构件不弄崩页面
形式槽混进括号,或形式数与 locale 的要求不符时,引擎按块捕获错误,把构件用全角括号(U+FF5B / U+FF5D)原样输出:
supports 5 {plural 5: язык|языка}
全角括号看着和 ASCII {} 几乎一样,却是不同的码位——它们穿过后续工序而不会被枚举解析器误读。页面照常渲染,bug 在 HTML 里可见,运维不用面对 500 就能修。
校验器(和演练场)跑的是严格模式——两类错误都会抛出,带 position 字段和构件原文。编辑在模板进生产之前就能抓到。
Locale 从哪来
一次渲染一个 locale。v1 没有按构件的覆盖。
- WordPress 插件:模板级 post meta
_spintax_locale优先;回落到站点 locale(get_locale())。 @spintax/core(独立使用):render(tpl, { locale })的locale字段。来源由宿主决定——请求头、用户设置、站点配置。不传就是两形式默认。- 演练场:跟随页面语言——/play/ 是英语,/ru/play/ 是俄语。没有页内 locale 切换;用导航的语言切换器换页。
locale 字符串归一化到基础标签——ru-RU → ru,uk_UA → uk,pt-BR → pt。形式数表按基础标签查。文字和地区子标签不携带复数语法,所以 sr-Latn、sr-Cyrl、sr_RS 和 sr-Latn-RS 全部归一到 sr、拿到同样的三种形式。三字母标签不映射:srp 保持 srp,落到两形式默认。
流水线位置
1. strip comments
2. extract #set directives
3. apply conditionals (pass 1)
4. expand %var% references
5. apply conditionals (pass 2)
6. apply plurals ← this stage
7. resolve enumerations
8. resolve permutations
9. post-process
复数工序在变量展开之后运行(数量槽里的 %LangCount% 已经是字面整数字符串),在枚举解析之前运行(同义解析器没有任何机会误读坏构件)。
演练:产品对比
SaaS 对比目录里的三行。完全同一个模板渲染每一行,但数量既决定名词形式,也顺带决定文案的具体感。
| 产品 | 语言数 | 集成数 | 套餐数 |
|---|---|---|---|
| Acme | 16 | 18 | 3 |
| Beta | 1 | 1 | 5 |
| Gamma | 12 | 17 | 2 |
没有复数原语时,每个产品都渲染同样含糊的话:"supports many integrations, including Slack, GitHub, Linear"。目录里的差异不可见。有了原语,模板可以说:
supports %IntegrationCount% {plural %IntegrationCount%: integration|integrations},
including %TopIntegrations%
逐行渲染:
- Acme:supports 18 integrations, including Slack, GitHub, Linear
- Beta:supports 1 integration, including Slack
- Gamma:supports 17 integrations, including Slack, GitHub, Linear
事实差异现在进了文案。SEO 得到真实的差异化,读者得到具体数字而不是 "many"。
反模式
1. 封闭集合的内联配对
“碰巧能用”的变通法:
{50|100|150|200} баллов
被选中的每个数恰好都取 many 形式,名词永远不闹别扭。数一旦来自真实变量就碎:21–24 或 31–34 里的任何值都会配上错的形式。
2. 用 Has 标志分桶的条件
“工程师在环”的变通法:
%LangCount% {?HasOneLang?language|{?HasFewLangs?languages|languages}}
每个可数实体在拼装器里加三个布尔标志,每个模板里写嵌套条件。编辑无法自己写一个新的 %count% %noun% 句式——得先请工程师加标志三件套、发一版,然后才能写模板。这个原语就是为消灭这种流程而生的。
3. 用列表措辞绕开数量
“沉默回避”的变通法:
supports many integrations, such as %TopIntegrations%
工具表达不了数量,编辑就绕着数量写。结果:每个条目读起来一模一样,没有 SEO 差异化,没有编辑权威。把数量亮出来。
行业背景
复数一致在每个 i18n 技术栈里都是一等原语:ICU MessageFormat({count, plural, one {…} few {…} other {…}})、gettext 的 ngettext、FormatJS 等等。它属于任何严肃内容系统都绕不开的那类通用语法原语。
我们的不同做法:把它做成 spintax 原生原语。ICU 要求另一套模板语法——意味着迁移平台里已有的每一个 {a|b|c}。{plural N: …} 融进现有语法面——同样的花括号、同样的竖线、同样的“分工序组装”思维模型。
快速检查清单
- 任何数字 + 名词的渲染都用
{plural %N%: form1|form2|form3}。哪怕在“只需要”两形式的纯英文站上。 - 注意 locale 的形式数。RU/UK/BE 与 SR/HR/BS = 3,英语式 = 2。不匹配会被校验器抓住。
- 形式是纯文本。不嵌套
{}或[]——先用#def提取。不是#set:宏会把括号原样送回来。 - 空 / 非数字的数量 → 构件为空。想抹整句就用
{?HasFoo?…|}把门。 - 负数走
abs()。零取 many 形式。小数过不了严格数字检查——数量保持整数。 - locale 每模板(post meta)或每次渲染设一次。暂无按构件覆盖。
- 宽容运行时把坏构件用全角括号原样输出——可见的 bug,不是无声的损坏。校验器跑严格模式。
现场试试
演练场EN自带 {plural %Count%: language|languages} 示例。把 %Count% 依次拨到 0、1、2、5、11、21、22 看两形式(EN)的分桶规则,再用导航的语言切换器翻到俄语演练场,看同一个模板在三形式规则和俄语名词形式下的表现。
更喜欢桌面编辑器?Spintax Studio——Microsoft Store 里的 Windows 原生编辑器——边输入边校验、完全离线:它的诊断面板说的正是本页的这些代码(plural.arity、plural.count-macro),每个代码都有内置的帮助文章,而 locale 选择器切换引擎校验的形式数——在 en 和 ru 之间切换,就能在同一个模板上测试两套分桶规则。