Согласование с числом: {plural <count>: form1|form2|form3}

Русский — и любой славянский — требует, чтобы существительное согласовывалось с числительным перед ним: 1 язык, 2 языка, 5 языков. Выбор зависит от n % 100 и n % 10, с исключением для 11–14. Spintax — первый движок семейства spintax, у которого это first-class примитив. До этого каждый редактор переизобретал правило в шаблоне и обязательно где-то ошибался — или молча избегал конструкций «число + существительное» вообще.

Синтаксис

{plural <count>: form1|form2|form3}

Литеральный префикс {plural (с одним пробелом в конце) — однозначный дискриминатор от формы синонима {a|b|c}. Двоеточие : разделяет слот числа и слот форм. Формы разделены вертикальной чертой.

Семейство локалейЯзыкиФормы
Восточнославянские ru, uk, be 3: one|few|many
EN-style (по умолчанию) en, es, pt, de, it, fr, nl, sv, no, da, fi, … 2: one|many

Польский, чешский, словацкий, словенский, болгарский, арабский, валлийский, иврит и латышский имеют другую структуру корзин и намеренно не вошли в v1. Каждый язык будет добавлен по реальному запросу.

Примеры

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%, либо целочисленный литерал. К моменту работы плюрального прохода переменные уже подставлены, поэтому хелпер всегда видит в слоте числа строку с целым числом — независимо от того, в какой форме редактор его написал.

Правило локали (RU 3-bucket)

Русское правило знаменито своими нюансами. Полная таблица:

Число 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 (которые по последним цифрам выглядели бы как one и few) ломают любые workaround'ы. Хелпер закрывает этот разрыв один раз — для каждого редактора, каждого счётчика, каждого шаблона. Алгоритм:

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

Отрицательные числа берутся по модулю — это совпадает с CLDR. Ноль выбирает форму many («0 языков»), потому что это грамматически правильный рендер на русском, а не потому что ноль — особый случай.

Почему через двоеточие (а не {plural %N%|forms})

Ранний эскиз был {plural %LangCount%|язык|языка|языков} — только pipe. Две структурные проблемы убили эту форму:

1. Hazard helper-переменной. Распространённый preset-макрос:

#set %LangPlural% = {plural %LangCount%: язык|языка|языков}

Если бы конструкция превращалась после подстановки переменной в {12|язык|языка|языков} — она была бы неотличима от 4-вариантного синонима, и следующая стадия пайплайна радостно выбрала бы один наугад. Двоеточная форма сохраняет дискриминирующий префикс через раскрытие, поэтому плюральный проход безопасно идёт после подстановки переменных.

2. Литеральные числа. {30|день|дня|дней} сталкивается с формой синонима {a|b|c} — парсер не различит. Двоеточная форма {plural 30: день|дня|дней} структурно отличима.

Числовые краевые случаи

Слот числа парсится строго. Если бы слот «молча» означал не то, что ожидает редактор, конструкция вместо этого резолвится в пустую строку.

Слот числаРезультатПочему
12выбранная формаобычное целое
-3форма для 3abs(), как в 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} бросает PluralFormError. Так же ведёт себя {plural 1: [<and>day|days]}.

Если внутри формы действительно нужна условная или случайная составляющая — вынесите её в переменную:

/# неправильно: вложенный синоним в форме #/
{plural 1: {язык|наречие}|языка|языков}

/# правильно: extract через #set, ссылка на простую переменную #/
#set %LangNoun% = {язык|наречие}
{plural %Count%: %LangNoun%|%LangNoun%а|%LangNoun%ов}

HTML-теги (<em>, <a href="…">) и нераскрытые %Var% в тексте формы выживают без проблем — запрещены только структурные spintax-скобки.

Терпимый рантайм — кривая конструкция не обвалит страницу

Если в форму просочилась bracket-конструкция или количество форм не совпадает с локалью, движок ловит ошибку поблочно и выводит конструкцию буквой с fullwidth-скобками (U+FF5B / U+FF5D):

supports 5 {plural 5: язык|языка}

Fullwidth-скобки визуально почти неотличимы от ASCII {}, но это другие кодпоинты — следующие стадии пайплайна не примут их за синоним. Страница рендерится, баг виден прямо в HTML, и ops может починить без 500-й.

Валидатор (и песочница) работают в strict-режиме — оба класса ошибок бросают исключение с полем position и буквальным текстом конструкции. Редактор ловит ошибку до того, как шаблон уйдёт в прод.

Откуда берётся локаль

Одна локаль на вызов рендера. В v1 нет per-construct override.

  • WordPress-плагин: побеждает post meta шаблона _spintax_locale; фолбэк — site-локаль WP (get_locale()).
  • TypeScript-движок (standalone): второй аргумент applyPlurals(text, lang). Источник выбирает хост — заголовок запроса, настройка пользователя, конфиг сайта.
  • Песочница: через дропдаун Локаль; по умолчанию совпадает с языком страницы.

Строка локали нормализуется до базового тега — ru-RUru, uk_UAuk, pt-BRpt. Таблица arity ищется по базовому тегу.

Место в пайплайне

1. вырезать комментарии
2. извлечь #set
3. применить условия           (проход 1)
4. раскрыть %var%
5. применить условия           (проход 2)
6. применить плюрал             ← эта стадия
7. раскрыть перечисления
8. раскрыть permutations
9. post-process

Плюральный проход идёт после раскрытия переменных (так что %LangCount% в слоте числа уже целое-литерал) и до раскрытия перечислений (так что resolver синонимов не успевает спутать кривую конструкцию с обычным {a|b|c}).

Разобранный пример: сравнение продуктов

Три строки из каталога SaaS-продуктов. Один и тот же шаблон рендерит каждую строку, но число управляет и формой существительного, и — за счёт этого — ощущением конкретности копи.

ПродуктЯзыкиИнтеграцииТарифы
Acme16183
Beta115
Gamma12172

Без плюрального примитива каждый продукт рендерит одну и ту же расплывчатую фразу: «поддерживает множество интеграций, среди которых Slack, GitHub, Linear». Различия в каталоге невидимы. С примитивом шаблон может сказать:

поддерживает %IntegrationCount% {plural %IntegrationCount%: интеграцию|интеграции|интеграций},
среди которых %TopIntegrations%

Рендерится по строкам:

  • Acme: поддерживает 18 интеграций, среди которых Slack, GitHub, Linear
  • Beta: поддерживает 1 интеграцию, среди которых Slack
  • Gamma: поддерживает 17 интеграций, среди которых Slack, GitHub, Linear

Фактическое различие теперь в копи. SEO выигрывает от честной дифференциации; читатель — от конкретных чисел вместо «множество».

Анти-паттерны

1. Закрытое inline-спаривание

Workaround «работает по случайности»:

{50|100|150|200} баллов

Каждое выбранное число попадает в форму many, поэтому существительное никогда не расходится. Ломается в момент, когда число становится переменной — любое значение в 21–24 или 31–34 даст «1 балл / 2 балла», а не «25 баллов».

2. Has-флаги через условия

Workaround «engineer-in-the-loop»:

%LangCount% {?HasOneLang?язык|{?HasFewLangs?языка|языков}}

Добавьте три булевых флага на каждую считаемую сущность в ассемблере, пишите вложенные условия в каждом шаблоне. Редактор не может добавить новую конструкцию %число% %существительное%, не попросив инженера сначала добавить тройку флагов, выкатить сборку, и только потом писать шаблон. Этот workflow примитив и пришёл устранить.

3. Списочные обёртки вместо чисел

Workaround «молчаливое избегание»:

поддерживает множество интеграций, среди которых %TopIntegrations%

Редактор обходит число, потому что инструмент не может его выразить. Результат: каждая запись читается одинаково, никакой SEO-дифференциации, никакой редакторской авторитетности. Выводите число.

Контекст индустрии

Согласование с числом — first-class примитив в любом i18n-стэке: ICU MessageFormat ({count, plural, one {…} few {…} other {…}}), gettext ngettext, FormatJS и т.д. Это та же категория универсальных грамматических примитивов, без которых ни одна серьёзная контентная система не выходит.

Что мы сделали иначе: примитив стал родным для spintax. ICU требует другой шаблонный синтаксис — это значило бы мигрировать каждый существующий {a|b|c} на платформе. {plural N: …} вписывается в существующую поверхность — те же фигурные скобки, те же pipe-разделители, та же ментальная модель «компонуй стадиями».

Короткий чеклист

  • Используйте {plural %N%: form1|form2|form3} для любого рендера число + существительное. Даже на EN-only сайтах, где «нужно всего 2 формы».
  • Следите за arity локали. RU/UK/BE = 3 формы. EN-style = 2. Несовпадение ловит валидатор.
  • Формы — обычный текст. Никаких вложенных {} или [] — выносите через #set.
  • Пустое / не-число → пустая конструкция. Если нужно стирать всё предложение — оберните в {?HasFoo?…|}.
  • Отрицательные через abs(). Ноль выбирает many. Дроби не проходят strict-парсинг — держите счётчики целыми.
  • Локаль ставится один раз на шаблон (post meta) или на вызов рендера. Per-construct override пока нет.
  • Lenient runtime рендерит кривое буквой с fullwidth-скобками — видимый баг, не молчаливая порча. Валидаторы работают strict.

Попробовать вживую

В песочнице есть пример {plural %Count%: language|languages}. Переключайте %Count% через 0, 1, 2, 5, 11, 21, 22 и меняйте локаль между en и ru — увидите логику корзин, не написав ни строки кода.


Продолжить серию