复数一致:{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 家族语言形式数
东斯拉夫 ruukbe 3one|few|many
BCS srhrbs 3one|few|many
英语式(默认) enesptdeitfrnlsvnodafi、… 2one|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, 121one1 язык, 21 язык
2–4, 22–24, 32–34, …few2 языка, 23 языка
0, 5–20, 25–30, 35–40, …, 100, 111–114many0 языков, 11 языков, 25 языков

11–14 的例外(按末位数字看本该像 onefew)正是变通法栽跟头的地方。引擎助手替每个编辑、每个计数、每个模板一次性堵上这个缺口。算法:

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选中的形式普通整数
-33 的形式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-RUruuk_UAukpt-BRpt。形式数表按基础标签查。文字和地区子标签不携带复数语法,所以 sr-Latnsr-Cyrlsr_RSsr-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 对比目录里的三行。完全同一个模板渲染每一行,但数量既决定名词形式,也顺带决定文案的具体感。

产品语言数集成数套餐数
Acme16183
Beta115
Gamma12172

没有复数原语时,每个产品都渲染同样含糊的话:"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.arityplural.count-macro),每个代码都有内置的帮助文章,而 locale 选择器切换引擎校验的形式数——在 enru 之间切换,就能在同一个模板上测试两套分桶规则。


继续本系列