Plural agreement: {plural <count>: 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 <count>: 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 familyLanguagesForms
East Slavic ru, uk, be 3: one|few|many
BCS sr, hr, bs 3: one|few|many
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.

Arabic, 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}
поддерживает %LangCount% {plural %LangCount%: язык|языка|языков}
получите %BonusCount% {plural %BonusCount%: бонус|бонуса|бонусов}
processed in {plural %PayoutHours%: hour|hours}
завершить за {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 nBucketRU example
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 языков

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:

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 slotResultWhy
12picked formplain integer
-3picked form for 3abs() matches CLDR
0picked form (RU: many; EN: many)zero is grammatical
12 picked form for 12whitespace trimmed
(empty)entire construct → emptymissing count
%MissingVar% (didn't substitute)entire construct → emptynot a number after expansion
1,200entire construct → emptycomma is not a digit; parseInt would lie and return 1
12abc / 08hentire construct → emptytrailing non-digit chars rejected
1.5entire construct → emptyintegers 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: [<and>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 (<em>, <a href="…">) 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/, Russian on /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-RUru, uk_UAuk, pt-BRpt. 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.

ProductLanguagesIntegrationsPlans
Acme16183
Beta115
Gamma12172

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.

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|form3} 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. 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. 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 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 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 — 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