Разработка расширений

Как устроено расширение Lil_CMS: манифест, плагин, события, миграции и установка.

Правила, из которых всё следует

  • Ядро не знает о расширениях. Расширение подписывается на события ядра, а не правит его файлы. Обновление системы не должно ломать вашу работу.
  • Расширение самодостаточно. Свои классы, шаблоны, строки языка и миграции лежат внутри его каталога.
  • Строки интерфейса — только через переводчик. Зашитый в код текст нельзя перевести, а язык в системе выбирает владелец сайта.
  • Права проверяются в диспетчере. Действие без объявленного права недоступно никому — так безопаснее, чем «разрешено, пока не запретили».

Полный текст этих правил и примеры лежат в файле docs/EXTENSIONS.md внутри самой системы — на сайте собрано главное.

Каталог расширения

Вид расширения виден по приставке имени: com_ — компонент, mod_ — модуль, plg_ — плагин. Тема лежит своим каталогом.

extensions/
  components/com_shop/        компонент магазина
    lil.json                  манифест
    src/                      классы
    templates/                шаблоны экранов и витрины
    language/                 строки ru-RU.ini, en-GB.ini
    migrations/               таблицы расширения
  modules/mod_menu/           модуль меню сайта
  plugins/shop/plg_shop_discounts/   плагин скидок
  themes/lil_default/         тема сайта

Файл lil.json

Манифест описывает расширение: имя, версию, автозагрузку классов, требования и настройки. Без него система расширение не увидит.

{
    "type": "component",
    "element": "com_example",
    "name": "Пример",
    "version": "0.0.00001",
    "description": "Что делает расширение — одной фразой.",
    "author": "Ваше имя",
    "license": "GPL-2.0-or-later",
    "provider": "Lil\\Component\\Example\\ExampleServiceProvider",
    "autoload": {
        "Lil\\Component\\Example\\": "src"
    },
    "requires": {
        "core": "0.0.00300",
        "php": "8.3"
    },
    "migrations": "migrations",
    "templates": "templates",
    "language": "language"
}

Версия растёт на единицу при каждой правке — тем же правилом, что и у самой системы. requires.core — минимальная версия ядра: без неё клиент не проверит совместимость перед установкой.

Плагин: подписка на события

Плагин ничего не знает о ядре, кроме имени события, и ядро ничего не знает о плагине. Так исправление в ядре не ломает плагин, а плагин не ломает сайт.

final class ExamplePlugin extends Plugin
{
    public function subscribe(EventBus $events): void
    {
        // Приоритет ниже нуля: сначала отработают те, кто собирает
        // страницу, и только потом мы правим готовый результат.
        $events->listen(ResponseEvent::class, $this->touch(...), -100);
    }

    private function touch(ResponseEvent $event): void
    {
        $response = $event->response();

        // Правим только HTML: подставлять текст в ответ API
        // значило бы сломать его для того, кто его разбирает.
        if (!str_contains((string) $response->header('Content-Type'), 'text/html')) {
            return;
        }

        // Response неизменяем: в событие кладётся новый объект,
        // правка «на месте» потерялась бы молча.
        $event->setResponse(new Response($body, $response->status()));
    }
}

События ядра

Шина событий типизирована: подписка идёт на класс события, а не на строку — опечатка в имени станет ошибкой сразу, а не молчанием в работе.

  • ResponseEvent — готовый ответ перед отправкой браузеру.
  • CollectBlocks — сбор блоков конструктора страниц: так компонент добавляет свой блок.
  • ShopPriceEvent — цена товара: витрина, корзина и заказ считают одинаково, поэтому скидки живут здесь.
  • CollectPaymentMethods, CollectShippingMethods — способы оплаты и доставки магазина.

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

Свои таблицы

Каждая миграция обязана уметь откатываться: метод down() возвращает базу в прежнее состояние. Это не формальность — на откате держится восстановление после неудачного обновления.

return new class () extends Migration {
    public function description(): string
    {
        return 'Пример: своя таблица';
    }

    public function up(Schema $schema): void
    {
        $schema->create('example_items', static function (Blueprint $table): void {
            $table->id();
            $table->string('title', 190)->nullable(false);
            $table->timestamps();
        });
    }

    public function down(Schema $schema): void
    {
        $schema->drop('example_items');
    }
};

Установка и жизненный цикл

php bin/lil extension:discover        найти расширения на диске
php bin/lil extension:install com_example --enable
php bin/lil extension:disable com_example
php bin/lil extension:uninstall com_example
php bin/lil version:bump com_example    поднять версию

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

Удаление снимает миграции расширения и убирает запись из реестра. Файлы остаются на диске — их удаляет тот, кто их туда положил.

Что проверяется перед выпуском

  • Весь вывод экранирован; сырой HTML — только после очистителя и по праву.
  • Изменяющие запросы проходят проверку токена формы.
  • Запросы к базе — подготовленными выражениями, без склейки строк.
  • Право на действие объявлено и проверяется в диспетчере.
  • Строки интерфейса переведены на русский и английский.
  • Ошибки не раскрывают путей, запросов и версий.

Готовое расширение собирается в подписанный пакет на сервере обновлений и попадает в каталог расширений — оттуда его ставят на другие сайты одной кнопкой.