Spintax для PHP

Движок, который рендерит spintax внутри WordPress-плагина, теперь поставляется отдельно: spintax/core, лицензия MIT, ноль рантайм-зависимостей, PHP 8.0+. Без WordPress, без фреймворка и без предположений о том, где лежат ваши шаблоны.

Что это — и чем не являются большинство PHP-библиотек для spintax

Поиск по Packagist выдаёт парсеры {a|b|c}. Это примитив замены, и его действительно достаточно — ровно до момента, когда текст должен согласоваться сам с собой.

Фразе «поддерживаем %n% языков» нужно, чтобы существительное совпало с числом. Фрагменту, который встречается дважды, нужно остаться тем же фрагментом. Блоку для платящих клиентов нужно условие, а не подбрасывание монеты. В spintax/core всё это — полноценные конструкции, а не склейка строк у вас в контроллере.

КонструкцияТипичная PHP-библиотекаspintax/core
Перечисление {a|b|c}дада
Перестановка [<config>a|b|c]редкода, с разделителями
Переменные %name%иногдатри области, заданный приоритет
Условия {?VAR?…}нетда
Согласование с числомнетда, по локали
Include #includeнетда, с защитой от циклов и глубины
Пост-обработканетпробелы, заглавные, экранирование URL

Установка

composer require spintax/core

PHP 8.0 или новее, ext-mbstring — и больше ничего. Никакой интеграции с фреймворком, никакого сервис-провайдера.

Быстрый старт

use Spintax\Core\Render\Pipeline;

$pipeline = new Pipeline();

echo $pipeline->render(
    '{Добро пожаловать в|Знакомьтесь —} %product%: поддерживает %n% {plural %n%: язык|языка|языков}.',
    ['product' => 'Acme', 'n' => '3'],
    locale: 'ru',
);
// → «Знакомьтесь — Acme: поддерживает 3 языка.»
// → «Добро пожаловать в Acme: поддерживает 3 языка.»   (перечисление выбирается
//    заново при каждом вызове; как зафиксировать — ниже, в «Детерминированности»)

Один объект, один вызов. Движок инстансный, а не статический фасад, потому что то, что настраивается один раз — откуда #include берёт шаблоны, какие глобальные переменные видит каждый шаблон, источник случайности — относится ко времени жизни пайплайна, а не к каждому рендеру.

API

Pipeline — вся публичная поверхность для обычного использования. Parser, Validator, Plurals и Conditionals лежат под ним для инструментов, которым нужна отдельная стадия, а не весь прогон.

ВызовЧто делает
Pipeline::render($raw, $runtime_vars, $context, $locale, $post_process)Прогоняет весь пайплайн в строку. Терпимо: кривая конструкция вырождается заметно, а не бросает.
Validator::validate($template, $known_slugs, $global_var_names, $locale)Возвращает ['errors' => […], 'warnings' => […]] — сообщение, строка, колонка. Валидно ⇔ ошибок нет.
Plurals::apply($text, $lang, $options)Только стадия плюралей, и по умолчанию она строгая: кривая конструкция бросает исключение. Чтобы вырождать, передайте ['lenient' => true]. Pipeline включает lenient за вас — поэтому render() и не бросает на содержимом шаблона.
Conditionals::apply($template, $variables)Только стадия условий.
Parser::*Отдельные стадии: комментарии, извлечение директив, развёртка переменных, перечисления, перестановки, пост-обработка, include.

Детерминированность

Параметра seed нет. Вместо него вы внедряете источник случайности — та же идея, но уровнем ниже:

$deterministic = new Pipeline(new Parser(fn(int $min, int $max) => $min));

Подойдёт любой callable вида fn(int $min, int $max): int, так что seeded-PRNG, фиксированный выбор или записанная последовательность из ваших фикстур подставляются одинаково — движку всё равно, что именно вы дали.

Тот же синтаксис, везде

Справочник по синтаксису применим дословно — это тот же язык, на котором говорят WordPress-плагин, JavaScript-пакет и песочница.

КонструкцияПримерСмысл
Перечисление{a|b|c}выбрать один (вложенность допустима)
Перестановка[<minsize=2;sep=", ">a|b|c]выбрать N, перемешать, склеить
Переменная%name%подставить значение
Локальный set#set %v% = valueмакрос — перекатывается при каждой ссылке
Локальный def#def %v% = valueраскатывается один раз за рендер и держится везде
Условие{?VAR?then|else}ветвление по значению
Plural{plural %n%: товар|товара|товаров}согласование по локали
Include#include "slug"встроить другой шаблон
Комментарий/# … #/вырезается до рендера

Согласование с числом — то, что обнаруживают позже всего

Записанное перечислением %n% {товар|товара|товаров} выбирает форму случайно. Она неверна для 1, неверна для 3 и неверна для 5 — и при этом читается нормально в том превью, куда вы случайно посмотрели. Конструкция plural выводит форму из числа:

{plural %n%: товар|товара|товаров}

Три формы для ru, uk, be, sr, hr, bs; две — для локалей английского типа. Правила корзин и краевые случаи разобраны в гайде по plural.

Знайте, чего не реализовано. У польского, чешского, словацкого, словенского и болгарского свои системы числа — движок не реализует ни одну из них, но и не отвергает их. Шаблон на pl будет разложен по английскому двухформному правилу, а это не польская грамматика. README пакета говорит об этом прямо; считайте вывод на этих локалях непроверенным.

Include: ваш ввод-вывод, безопасность движка

Получение шаблона — это I/O: запрос, чтение файла, поход в кэш. Значит, это дело приложения. Всё, что вокруг получения, остаётся в движке:

$pipeline = new Pipeline(
    source: fn(string $slug): ?string => $repository->findBySlug($slug),
);

Рекурсия, детект циклов, потолок глубины и бюджет на общее число включений действуют независимо от того, что делает ваш резолвер. Циклический include разрешается в пустоту, а не зацикливается. Разделение сознательное: наивный хост не должен иметь возможности повесить себя шаблоном.

Чего здесь намеренно нет

Кэширования, хранилища шаблонов, настроек — и санитизации вывода. render() возвращает текст до санитизации. Хост, который отдаёт HTML, обязан прогнать результат через свой санитайзер: WordPress-плагин применяет wp_kses_post(), а вы примените то, чего требует ваш контекст.

Об этом стоит сказать прямо, а не закопать: движок, который угадывает ваши правила экранирования, — это движок, с которым вы потом воюете. Он рендерит текст и отдаёт его обратно.

Паритет обеспечен, а не заявлен

Общий golden-корпус языконезависимых фикстур (шаблон, контекст, локаль, seed) → ожидаемое — это и есть контракт между реализациями. Тестовый набор этого пакета и есть тот корпус: он выкачивается из JavaScript-репозитория, а не вендорится, потому что копия разъедется, а разъехавшийся контракт — уже не контракт.

Проверка работает в обе стороны: фикстура не попадёт в корпус, пока движки, которые она связывает, ей не удовлетворяют, а изменение движка не пройдёт, пока корпус не зелёный. Фикстуры гоняются по поставляемому Pipeline, а не по его тестовой копии.

Что не под паритетом — случайный выбор. Seeded-рендеры воспроизводимы внутри одного движка; одинаковые случайные последовательности между движками — сознательный non-goal. Валидный вариант вы получите в любом случае, просто не тот же самый.

В чём пакеты расходятся намеренно

Тот же язык, та же семантика, разная эргономика. @spintax/core даёт функциональный фасад с переиспользуемым AST и слоем для инструментов (analyze, neutralize); PHP-пакет даёт пайплайн плюс его стадии-примитивы и добавляет строгий режим плюралей, который бросает исключение — удобно за редактором. Диагностика тоже отличается по форме. Оба возвращают сообщение со строкой и колонкой; но только у JavaScript рядом лежит стабильный code под паритетом — именно он позволяет песочнице подставить переведённое сообщение. У PHP-валидатора поля с кодом нет, так что хост, которому нужно ветвиться по конкретной проблеме, разбирает структуру, а не идентификатор. Под паритетом между движками находится вердикт — валиден шаблон или нет, — а не формулировка, которой это объясняют.

Почему MIT, если плагин под GPL

Это извлечение, а не переписывание, и шов был прорезан ещё в исходном коде: у рендерера плагина с одной стороны был чистый оркестратор стадий, с другой — адаптер к WordPress, а файлы движка изначально не содержали ссылок на WordPress. Вынос свёлся почти целиком к удалению: константа-страж, пара прагм линтера и одна замена WordPress-хелпера для JSON на стандартный.

Авторские права на этот код принадлежат тому же автору — именно это и делает смену лицензии возможной в принципе. Движок — оригинальная реализация: GTW — это синтаксис, с которым движок совместим, а не кодовая база, от которой что-то здесь происходит; слои условий, плюралей и пост-обработки написаны с нуля. MIT совместим с GPL, поэтому GPL-плагин может потреблять MIT-пакет, тогда как обратное не работало бы.

Один движок, много поверхностей

  • OpenCart SEO — пинит spintax/core и распаковывает его в расширение. Реальный потребитель на сегодня.
  • WordPress-плагин — тот же код движка, но пока со своей копией, а не через зависимость от пакета. Риск расхождения уже закрыт кросс-движковым корпусом, так что консолидация здесь — уборка, а не починка.
  • Ваше приложение — джоба на Laravel, команда Symfony, обычный скрипт. У пакета нет мнения на этот счёт.