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
EN-style (default) en, es, pt, de, it, fr, nl, sv, no, da, fi, … 2: one|many

Polish, Czech, Slovak, Slovenian, Bulgarian, Arabic, Welsh, Hebrew and Latvian have different bucket structures and are intentionally not in v1. 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} raises a PluralFormError. So does {plural 1: [<and>day|days]}.

If you genuinely need conditional or random content inside a form, hoist it to a variable first:

/# wrong: nested synonym in form #/
{plural 1: {язык|наречие}|языка|языков}

/# right: extract via #set, reference plain variable #/
#set %LangNoun% = {язык|наречие}
{plural %Count%: %LangNoun%|%LangNoun%а|%LangNoun%ов}

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()).
  • TypeScript engine (standalone): the second argument to applyPlurals(text, lang). Hosts decide the source — request header, user setting, site config.
  • Playground: set via the Locale dropdown; defaults to the page language.

The locale string is normalised to its base tag — ru-RUru, uk_UAuk, pt-BRpt. The arity table looks up by base tag.

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 = 3 forms. EN-style = 2. Mismatch is caught by the validator.
  • Forms are plain text. No nested {} or [] — extract via #set first.
  • 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 and switch the locale between en and ru to see the bucket logic without writing a line of code.


Continue the series