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, обычный скрипт. У пакета нет мнения на этот счёт.