Согласование с числом: {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, 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 языков |
Исключения для 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 | форма для 3 | 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} бросает 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-RU → ru, uk_UA → uk, pt-BR → pt. Таблица arity ищется по базовому тегу.
Место в пайплайне
1. вырезать комментарии
2. извлечь #set
3. применить условия (проход 1)
4. раскрыть %var%
5. применить условия (проход 2)
6. применить плюрал ← эта стадия
7. раскрыть перечисления
8. раскрыть permutations
9. post-process
Плюральный проход идёт после раскрытия переменных (так что %LangCount% в слоте числа уже целое-литерал) и до раскрытия перечислений (так что resolver синонимов не успевает спутать кривую конструкцию с обычным {a|b|c}).
Разобранный пример: сравнение продуктов
Три строки из каталога SaaS-продуктов. Один и тот же шаблон рендерит каждую строку, но число управляет и формой существительного, и — за счёт этого — ощущением конкретности копи.
| Продукт | Языки | Интеграции | Тарифы |
|---|---|---|---|
| Acme | 16 | 18 | 3 |
| Beta | 1 | 1 | 5 |
| Gamma | 12 | 17 | 2 |
Без плюрального примитива каждый продукт рендерит одну и ту же расплывчатую фразу: «поддерживает множество интеграций, среди которых 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 — увидите логику корзин, не написав ни строки кода.