# Plural agreement: `{plural : form1|form2|form3}` Russian — and every Slavic language — needs the noun to agree with the cardinal number in front of it: **1 язык, 2 языка, 5 языков**. The choice depends on the count modulo 100 and 10, with exceptions for 11–14. **Spintax is the first spintax-family engine to ship this as a first-class primitive.** Before, every editor reinvented it in templates and got it wrong somewhere — or silently avoided number-bearing constructions altogether. ## The syntax ``` {plural : form1|form2|form3} ``` The literal prefix `{plural` (with one trailing space) is the unambiguous discriminator from the synonym shape `{a|b|c}`. The `:` separates the count slot from the forms slot. Forms are pipe-separated. | Locale family | Languages | Forms | | --- | --- | --- | | East Slavic | `ru`, `uk`, `be` | **3**: `one|few|many` | | BCS | `sr`, `hr`, `bs` | **3**: `one|few|many` | | Arabic | `ar` | **6**: `zero|one|two|few|many|other` | | EN-style (default) | `en`, `es`, `pt`, `de`, `it`, `fr`, `nl`, `sv`, `no`, `da`, `fi`, … | **2**: `one|many` | Serbian, Croatian and Bosnian reuse the East Slavic bucket rule character for character — _one_ for 1, 21, 101 (but not 11), _few_ for 2–4, 22–24 (but not 12–14), _many_ for everything else including zero. CLDR labels that third bucket `other` rather than `many` for BCS; positionally it is the same slot, so a template written for Russian arity works unchanged. **Everything outside those two families and Arabic falls back to the 2-form rule — silently.** Polish, Czech, Slovak, Slovenian and Bulgarian have plural systems of their own, and the engine does not implement them and does not reject them either. A `pl` template is bucketed by the English rule, which is not Polish grammar. If you write for one of those locales, treat the output as unverified rather than assuming the engine knows the language. Arabic has its own six-form rule since `@spintax/core` 0.11.0: `zero|one|two|few|many|other` in CLDR order, _few_ for last two digits 3–10, _many_ for 11–99, and zero gets a form of its own. Welsh, Hebrew and Latvian have different bucket structures and are intentionally _not_ implemented. They will land per language as real demand surfaces. ### Examples ``` supports %LangCount% {plural %LangCount%: language|languages} ships with %IntegrationCount% {plural %IntegrationCount%: integration|integrations} processed in {plural %PayoutHours%: hour|hours} поддерживает %LangCount% {plural %LangCount%: язык|языка|языков} получите %BonusCount% {plural %BonusCount%: бонус|бонуса|бонусов} завершить за {plural 30: день|дня|дней} ``` The count slot accepts either a `%Var%` reference or a literal integer. By the time the plural pass runs, variable substitution has already happened — so the helper only ever sees an integer string in the count slot, regardless of which form the editor wrote. ## The locale rule (RU 3-bucket) The Russian rule is famously fiddly. The full table: | Count `n` | Bucket | RU example | | --- | --- | --- | | `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 языков | The exceptions for 11–14 (which would otherwise look like _one_ and _few_ by their last digits) trip up workarounds. The engine helper closes the gap once for every editor, every counter, and every template. Algorithm: ```js 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 ``` Negative numbers use `abs`, matching CLDR. Zero picks the _many_ form ("0 языков") because that's the grammatically correct rendering in Russian, not because zero is a special case. ## Why colon-delimited (and not `{plural %N%|forms}`) An earlier sketch wrote `{plural %LangCount%|язык|языка|языков}` with pipes only. Two structural problems killed that form: **1\. Helper-var hazard.** A common preset macro: ``` #set %LangPlural% = {plural %LangCount%: язык|языка|языков} ``` If the construct were `{12|язык|языка|языков}` after variable substitution, it would be indistinguishable from a 4-way enum synonym — and the next pipeline stage would happily pick one at random. The colon form preserves the discriminating prefix through expansion, so the plural pass can safely run _after_ variable substitution. **2\. Literal integers.** `{30|день|дня|дней}` collides with synonym `{a|b|c}` shape — the parser cannot tell them apart. The colon form makes `{plural 30: день|дня|дней}` structurally distinct. ## Numeric edge cases The count slot is parsed strictly. If a slot would silently mean something different from what the editor expects, the construct resolves to an empty string instead. | Count slot | Result | Why | | --- | --- | --- | | `12` | picked form | plain integer | | `-3` | picked form for `3` | `abs()` matches CLDR | | `0` | picked form (RU: many; EN: many) | zero is grammatical | | `12` | picked form for `12` | whitespace trimmed | | (empty) | **entire construct → empty** | missing count | | `%MissingVar%` (didn't substitute) | **entire construct → empty** | not a number after expansion | | `1,200` | **entire construct → empty** | comma is not a digit; `parseInt` would lie and return `1` | | `12abc` / `08h` | **entire construct → empty** | trailing non-digit chars rejected | | `1.5` | **entire construct → empty** | integers only in v1 | If you want sentence-erase on missing count (instead of just the construct), gate the whole sentence with a conditional: ``` {?HasLanguages?supports %LangCount% {plural %LangCount%: language|languages}|} ``` ## Form slot — no nested spintax brackets Forms must be plain text. They **cannot** contain nested spintax brackets `{` `}` `[` `]`. A form like `{plural 1: {a|b}|c}` is rejected, and so is `{plural 1: [day|days]}`. The validator reports it as an error; the runtime is lenient and degrades the block to fullwidth braces rather than throwing (see below). If you genuinely need conditional or random content inside a form, hoist it to a variable first — and it must be `#def`, not `#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 ``` This is the distinction that makes `#def` worth having. `#set` substitutes its value verbatim at every reference, so hoisting a synonym into a `#set` puts the brackets back into the slot you were trying to keep clean. `#def` rolls its value once per render and hands the form slot the resolved text. Note what the hoisted value is: a **non-inflecting** fragment, the same in every form. That is the only shape this pattern supports. Do not hoist a noun and try to build its forms by concatenating suffixes onto the variable — `%Noun%а` works for one synonym and produces garbage for the next. When the word itself inflects, write the forms out in full; that is what the form slots are for. HTML tags (``, ``) and unresolved `%Var%` survive harmlessly in form text — only _structural_ spintax brackets are forbidden. ## Lenient runtime — broken constructs do not crash the page If a form-slot bracket sneaks in, or the form count doesn't match the locale's arity, the engine catches the error per-block and emits the construct verbatim with **fullwidth braces** (`U+FF5B` / `U+FF5D`): ``` supports 5 {plural 5: язык|языка} ``` The fullwidth braces look almost identical to ASCII `{}` but are distinct codepoints — they survive the next pipeline stages without being mis-parsed by the enumeration resolver. The page renders, the bug is visible in the HTML, and ops can fix it without a 500. The validator (and the playground) runs in **strict mode** instead — both error classes throw with a `position` field and the verbatim construct text. Editors catch the mistake before the template hits production. ## Where the locale comes from One locale per render call. No per-construct override in v1. - **WordPress plugin:** per-template post meta `_spintax_locale` wins; falls back to the WordPress site locale (`get_locale()`). - **`@spintax/core` (standalone):** the `locale` field of `render(tpl, { locale })`. Hosts decide the source — request header, user setting, site config. Omit it and you get the 2-form default. - **Playground:** follows the page language — English on [/play/](https://spintax.net/play/), Russian on [/ru/play/](https://spintax.net/ru/play/). There is no per-page locale control; switch pages with the nav language switcher. The locale string is normalised to its base tag — `ru-RU` → `ru`, `uk_UA` → `uk`, `pt-BR` → `pt`. The arity table looks up by base tag. Script and region subtags carry no plural grammar, so `sr-Latn`, `sr-Cyrl`, `sr_RS` and `sr-Latn-RS` all normalise to `sr` and get the same three forms. Three-letter tags are _not_ mapped: `srp` stays `srp` and falls to the 2-form default. ## Pipeline placement ``` 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 ``` The plural pass runs **after** variable expansion (so `%LangCount%` in the count slot is already a literal integer string) and **before** enumeration resolution (so the synonym resolver never has a chance to misinterpret a malformed construct). ## Worked example: product comparison Three rows from a SaaS comparison catalogue. The exact same template renders each one, but the count drives both the noun form and — by extension — the perceived specificity of the copy. | Product | Languages | Integrations | Plans | | --- | --- | --- | --- | | Acme | 16 | 18 | 3 | | Beta | 1 | 1 | 5 | | Gamma | 12 | 17 | 2 | Without a plural primitive, every product renders the same vague phrase: "supports many integrations, including Slack, GitHub, Linear". Differences in the catalogue are invisible. With the primitive, the template can say: ``` supports %IntegrationCount% {plural %IntegrationCount%: integration|integrations}, including %TopIntegrations% ``` Renders per row: - Acme: _supports 18 integrations, including Slack, GitHub, Linear_ - Beta: _supports 1 integration, including Slack_ - Gamma: _supports 17 integrations, including Slack, GitHub, Linear_ The factual difference is now in the copy. SEO benefits from authentic differentiation; readers benefit from concrete numbers instead of "many". ## Anti-patterns ### 1\. Closed-set inline pairing The "works by accident" workaround: ``` {50|100|150|200} баллов ``` Every chosen number happens to take the _many_ form, so the noun never disagrees. Breaks the moment the number comes from a real variable — any value in 21–24 or 31–34 will pair with the wrong form. ### 2\. Has-flag bucket conditionals The "engineer-in-the-loop" workaround: ``` %LangCount% {?HasOneLang?language|{?HasFewLangs?languages|languages}} ``` Add three boolean flags per countable entity in the variable assembler, write nested conditionals in every template. The editor cannot author a new `%count% %noun%` construction without first asking an engineer to add the flag triple, ship a build, and only then write the template. This is the workflow the primitive was built to eliminate. ### Conditionals can't do modulo arithmetic. The "1, 21, 31… except 11" rule needs `n % 10` and `n % 100`. `{?Flag?…}` is a truthy/falsy gate — it cannot compute. So even the nested-conditional workaround can never be made _correct_ for Russian, only _plausible_. Use the plural primitive. ### 3\. List wrappers instead of counts The "silent avoidance" workaround: ``` supports many integrations, such as %TopIntegrations% ``` Editors write around the count because the tool can't express it. The result: every entry reads identically, no SEO differentiation, no editorial authority. Surface the count. ## Industry context Plural agreement is a first-class primitive in every i18n stack: ICU MessageFormat (`{count, plural, one {…} few {…} other {…}}`), gettext `ngettext`, FormatJS, etc. It belongs to the same category of universal grammatical primitives that no serious content system can ship without. **What we did differently:** we made it a spintax-native primitive. ICU requires a different template syntax, which would mean migrating every existing `{a|b|c}` in the platform. `{plural N: …}` fits the existing surface — same braces, same pipes, same "compose by stages" mental model. ## Quick checklist - Use `{plural %N%: form1|form2|…}` for any rendering of _number + noun_. Even on EN-only sites where you "only need" 2 forms. - Mind the locale's arity. RU/UK/BE and SR/HR/BS = 3 forms. AR = 6. EN-style = 2. Mismatch is caught by the validator. - Forms are plain text. No nested `{}` or `[]` — extract via `#def` first. Not `#set`: a macro puts the brackets right back. - Empty / non-numeric count → empty construct. Gate with `{?HasFoo?…|}` if you want sentence-erase. - Negative numbers use `abs()`. Zero picks the _many_ form (under `ar`, its own _zero_ form). Decimals fail strict numeric — keep counts integer. - Set the locale once per template (post meta) or per render call. No per-construct override yet. - Lenient runtime renders broken constructs verbatim with fullwidth braces — visible bug, not silent corruption. Validators run strict. ## Try it live The [playground](https://spintax.net/play/) ships with a `{plural %Count%: language|languages}` example. Toggle `%Count%` through 0, 1, 2, 5, 11, 21, 22 to see the 2-form (EN) bucket rule, then flip to the [Russian playground](https://spintax.net/ru/play/) via the nav language switcher to see the same template with the 3-form rule and Russian noun forms. Prefer a desktop editor? [Spintax Studio](https://spintax.net/spintax-editor.md#studio) — the native Windows editor from the Microsoft Store — validates as you type, offline: its diagnostics panel speaks the same codes this page uses (`plural.arity`, `plural.count-macro`), each with its own built-in help article, and a locale selector switches the arity the engine checks — flip between `en` and `ru` to test both bucket rules on one template. --- ## Continue the series - [Reverse authoring mindset](https://spintax.net/docs/authoring-mindset.md): Write the text first. Add markup last. - [Variables & multi-site reuse](https://spintax.net/docs/variables.md): Precedence, reroll gotchas, separator collisions. - [Permutations in practice](https://spintax.net/docs/permutations.md): Lists, title-style headings, separator rules. - [Grammar-safe synonymization](https://spintax.net/docs/grammar-safe-spintax.md): Binding rules. English agreement. Russian cases: families, prepositions, gender. - [Template composition](https://spintax.net/docs/template-composition.md): Variables as rendered HTML chunks. Item → section → orchestrator pipeline. - [Conditional spintax](https://spintax.net/docs/conditional-spintax.md): Value-driven {?VAR?then|else} — the syntax-level alternative to random enum picks. - [Repairing spintax templates](https://spintax.net/docs/repairing-spintax.md): Detect → one named recipe → re-check. Lint codes, the catalogue, the repair prompt. - [Spintax by example](https://spintax.net/examples.md): Every technique in the series, applied to one real paragraph, with real renders. [Open in playground](https://spintax.net/play/) [Back to all guides](https://spintax.net/docs.md) --- Source: https://spintax.net/docs/plural-spintax/