Сниппеты MODX: создание, вызов и параметры

Сниппет MODX выполняет PHP-код и возвращает результат на страницу MODX Revo
Сниппет MODX выполняет PHP-код и возвращает результат в место вызова. Его создают на вкладке «Элементы», а подключают тегом [[имя]] или [[!имя]] для некешируемой обработки. Параметры передаются после вопросительного знака и доступны через $scriptProperties. В уроке создаем вывод года, добавляем параметры и разбираем обращения к другим элементам MODX.
Содержание
  1. Что такое сниппет MODX
  2. Где находятся сниппеты
  3. Создаем первый сниппет year
  4. Нужны ли PHP-теги
  5. Почему return, а не echo
  6. Вызываем сниппет
  7. Добавляем год в подвал
  8. Кешируемый и некешируемый режим
  9. Параметры сниппета
  10. Год или диапазон лет
  11. Правила записи параметров
  12. Fenom — для сравнения
  13. Еще один пример: расчет числа по прошедшим периодам
  14. Как обращаться к другим элементам MODX из сниппета
  15. Обработать чанк с параметрами
  16. Запустить другой сниппет
  17. Получить поле текущего ресурса
  18. Получить TV текущего ресурса
  19. Если первый сниппет не работает
  20. Подборка полезных мини сниппетов для разработчиков
  21. Форматирование чисел и цен: numformat
  22. Удаление последнего символа: last
  23. Удаление разделителей из телефона: phone
  24. Склонение слов после целого числа: plural
  25. Кавычки и амперсанды в HTML-атрибутах
  26. Совместимость со старым модификатором title
  27. Передача значения из GET в форму: getGet
  28. Оценка времени чтения: readtime
  29. Хлебные крошки в JSON-LD: breadcrumbJsonLd
  30. Языковые версии: hreflangLinks
  31. Размер и расширение локального файла: FileSize
  32. FaviconGenerator — генерация иконок внутри MODX
  33. Проверяем генерацию
  34. Следующий урок

В уроке чанков мы подключали фрагменты разметки и передавали им данные. Теперь разберем сниппеты: элементы, которые выполняют PHP-код и возвращают результат.

Создадим сниппет year для вывода текущего года, добавим к нему параметр и посмотрим, как из одного универсального примера получать разные результаты.

Практику выполняем стандартным синтаксисом MODX. Варианты Fenom приведены для сравнения, включать его ради этого урока не требуется.

Что такое сниппет MODX

Сниппет — элемент с PHP-кодом. После вызова он может получить данные, выполнить расчет и вернуть текст, число, HTML или другой результат.

Сниппеты используются для меню, списков материалов, поиска, форм и других функций. Их можно создавать самостоятельно или устанавливать в составе готовых дополнений.

Главное отличие от чанка: чанк хранит разметку, а сниппет выполняет серверную логику. Сниппет также может передать полученные данные в чанк, чтобы не собирать оформление внутри PHP.

Где находятся сниппеты

В менеджере откройте вкладку «Элементы» и раздел «Сниппеты». Здесь находятся как установленные пакетами, так и созданные вручную элементы.

Обычные сниппеты хранятся в базе данных, в таблице с именем вида modx_site_snippets. modx_ — префикс установки. Для работы с ними не нужно редактировать таблицу вручную.

Создаем первый сниппет year

  1. На вкладке «Элементы» откройте контекстное меню раздела «Сниппеты» и выберите создание нового элемента.
  2. Укажите имя year.
  3. Добавьте описание: «Вывод текущего года».
  4. Категорию выберите при необходимости.
  5. Вставьте код ниже и сохраните сниппет.
return date('Y');

Создание сниппета year

Здесь date(‘Y’) получает текущий год по часовому поясу PHP-окружения. return возвращает его в MODX для подстановки вместо вызова.

Нужны ли PHP-теги

В поле кода сниппета достаточно тела PHP-кода, как в нашем примере. Открывающий тег <?php добавлять необязательно, закрывающий ?> не ставим.

Не вставляйте обычный HTML вне PHP-кода. Если нужно вернуть разметку, она должна быть строкой результата или обрабатываться через чанк.

Почему return, а не echo

Сниппет должен вернуть результат туда, где его вызвали. Поэтому для обычного вывода используем return.

echo непосредственно выводит данные и может нарушить ожидаемый порядок формирования страницы. Не используйте его вместо return только потому, что в отдельном PHP-примере так печатается текст.

Вызываем сниппет

Стандартный кешируемый вызов:

[[year]]

Некешируемый вызов:

[[!year]]

Для проверки добавьте в содержимое учебного ресурса:

<p>Текущий год: [[!year]]</p>
  1. Сохраните ресурс.
  2. Откройте его на сайте.
  3. Проверьте, что вместо тега появился год.

Вызов можно размещать в обрабатываемом MODX содержимом ресурса, шаблоне или чанке. В PHP другого сниппета используется отдельный метод API, который рассмотрим ниже.

Добавляем год в подвал

Если в предыдущем уроке создали siteFooter, откройте его и замените <p>Учебный сайт на MODX.</p> на:

<p>&copy; [[!year]] [[++site_name:htmlent]]. Все права защищены.</p>

site_name — название сайта из настроек. Год вычисляет сниппет.

Выводим копирайтинг

Это пример места для вызова в подвале. Создавать шаблон base или переносить готовый дизайн ради него не требуется.

Кешируемый и некешируемый режим

Запись Режим
[[year]] Кешируемый вызов: результат может войти в кеш ресурса.
[[!year]] Некешируемый вызов: обрабатывается на соответствующем этапе генерации ответа MODX.

У year результат меняется редко. Однако при кешируемом вызове после смены года старое значение может оставаться до обновления кеша. Для учебного примера используем !, чтобы год вычислялся при обработке запроса MODX.

Не переносите этот выбор на все сниппеты автоматически. Для постоянного списка кеширование может быть полезно, а персональные данные или обработка формы требуют другого режима.

Восклицательный знак не отключает собственный кеш компонента, серверный кеш или CDN. Также не путайте кеш результата страницы с хранением скомпилированного кода сниппета.

Параметры сниппета

Параметры позволяют менять поведение одного элемента без создания нескольких копий. Добавим в year год начала работы сайта.

Параметр указывается после вопросительного знака:

[[!year? &start=`2022`]]

В PHP параметры доступны через массив $scriptProperties. MODX также предоставляет переменные с соответствующими именами, например $start.

Для получения значения с запасным вариантом удобно использовать:

$start = $modx->getOption('start', $scriptProperties, '');

Здесь start — имя параметра, $scriptProperties — переданные свойства, а пустая строка — значение по умолчанию.

Год или диапазон лет

Полностью замените код существующего year следующей версией:

$currentYear = (int) date('Y');
$startValue = $modx->getOption('start', $scriptProperties, '');

if (!is_scalar($startValue)) {
    return (string) $currentYear;
}

$startText = trim((string) $startValue);

if (!preg_match('/^[0-9]{4}$/D', $startText)) {
    return (string) $currentYear;
}

$startYear = (int) $startText;

if ($startYear < 1000 || $startYear >= $currentYear) {
    return (string) $currentYear;
}

return $startYear . ' - ' . $currentYear;

Поведение этой версии:

  • Без start выводится текущий год.
  • Если передан подходящий прошлый год, выводится диапазон.
  • При текущем, будущем или некорректном значении остается текущий год.

В примере принимаем четырехзначный год от 1000 до текущего. Это проверка выбранного формата параметра, а не автоматическое определение даты основания сайта.

Вызов в подвале:

<p>&copy; [[!year? &start=`2022`]] [[++site_name:htmlent]]. Все права защищены.</p>

В 2026 году получится диапазон 2022 — 2026. В следующем году код рассчитает новое значение.

Правила записи параметров

  • После имени сниппета ставится ?.
  • Каждый параметр начинается с амперсанда.
  • Значение заключается в обратные кавычки.
  • Регистр имени важен: start и Start — разные ключи параметров.

Запись параметра в PHP-коде не создает автоматически строку на вкладке «Свойства». Если хотите управлять значением через интерфейс свойств, добавьте его там отдельно.

Свойства по умолчанию и наборы параметров

Повторяемые параметры можно сохранить в свойствах сниппета или отдельном наборе.

Например, создайте набор Copyright, свяжите его со сниппетом year и добавьте свойство start со значением 2022. Управлять наборами можно через свойства элемента или раздел наборов свойств в меню менеджера.

Вызов:

[[!year@Copyright]]

Переопределение значения для одного места:

[[!year@Copyright? &start=`2024`]]

Приоритет: свойства элемента, затем выбранный набор, затем параметры непосредственно в вызове. Поэтому start из последнего примера заменит значение набора.

Для первого упражнения создавать набор необязательно: короткий параметр можно оставить прямо в теге.

Fenom — для сравнения

При настроенной обработке Fenom сниппет вызывается так:

{'!year' | snippet}

С параметром:

{'!year' | snippet : [
    'start' => '2022'
]}

Вариант через microMODX:

{$_modx->runSnippet('!year', [
    'start' => '2022'
])}

Это альтернативные записи, а не несколько вызовов для одновременной вставки.

Если нужен только текущий год, в интеграции pdoTools есть готовый модификатор:

{'' | date : 'Y'}

Для него не требуется включать pdotools_fenom_php: date уже зарегистрирован как модификатор. Нужна сама настроенная обработка Fenom в месте использования.

В таком случае отдельный year для этой задачи не обязателен. Но это не отменяет сниппеты вообще: они нужны для собственной серверной логики.

Настройку второго синтаксиса разберем в уроке Fenom. Сейчас продолжаем стандартными вызовами.

Еще один пример: расчет числа по прошедшим периодам

В прежней практике был блок с несколькими числовыми показателями:

Блок с цифрами

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

Такой расчет не измеряет реальное количество клиентов, заказов или проектов. Для настоящей статистики нужен соответствующий источник данных, а не увеличение по календарю.

Универсальный сниппет counts с тремя параметрами

Чтобы не создавать отдельный сниппет для каждого интервала, передадим параметры:

Параметр Назначение Пример
start Начальное целое число. 232
startdata Дата начала в формате YYYY-MM-DD. 2024-01-29
deys_shag Длина периода в днях, больше нуля. 7

Имена startdata и deys_shag сохранены из исходного примера, чтобы не менять существующие вызовы. Создайте сниппет counts:

$base = filter_var(
    $modx->getOption('start', $scriptProperties, 0),
    FILTER_VALIDATE_INT
);
$base = $base === false ? 0 : $base;

$step = filter_var(
    $modx->getOption('deys_shag', $scriptProperties, 7),
    FILTER_VALIDATE_INT
);
$dateValue = $modx->getOption('startdata', $scriptProperties, '');

if ($step === false || $step < 1 || !is_scalar($dateValue)) {
    return (string) $base;
}

$dateText = trim((string) $dateValue);

if (!preg_match('/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/D', $dateText)) {
    return (string) $base;
}

$startDate = DateTimeImmutable::createFromFormat('!Y-m-d', $dateText);
$errors = DateTimeImmutable::getLastErrors();

if (!$startDate || ($errors !== false && (
    $errors['warning_count'] > 0 || $errors['error_count'] > 0
))) {
    return (string) $base;
}

$today = new DateTimeImmutable('today');

if ($startDate > $today) {
    return (string) $base;
}

$days = (int) $startDate->diff($today)->days;
$periods = (int) floor($days / $step);

return (string) ($base + $periods);

Вызовы:

[[!counts?
    &start=`232`
    &startdata=`2024-01-29`
    &deys_shag=`7`
]]

[[!counts?
    &start=`521`
    &startdata=`2024-01-29`
    &deys_shag=`3`
]]

До даты начала возвращается исходное число. Затем учитываются только полные периоды: для шага 7 за первые шесть дней прибавки нет, после семи дней прибавляется один.

Некорректная дата или неположительный шаг также возвращают начальное число. Расчет использует календарные дни, а не деление разницы секунд на 86400.

Аналогичный вызов на Fenom:

{'!counts' | snippet : [
    'start' => 232,
    'startdata' => '2024-01-29',
    'deys_shag' => 7
]}

Если готовый блок анимирует число JavaScript-кодом, проверьте, откуда он берет конечное значение. Оно может находиться в data-атрибуте, а не в видимом тексте элемента.

Как обращаться к другим элементам MODX из сниппета

В PHP-коде сниппету доступен объект $modx. Через его методы можно получать данные и вызывать другие элементы.

Ниже отдельные примеры для другого учебного сниппета. Не вставляйте вызов year внутрь самого year: это будет самовызов.

Обработать чанк с параметрами

Используем serviceCard из урока чанков:

return $modx->getChunk('serviceCard', [
    'title' => 'Подготовка материалов',
    'text' => 'Оформляем тексты и изображения для публикации.',
]);

Метод получает чанк по имени и обрабатывает его с переданными параметрами. Для этого примера нужен стандартный вариант serviceCard с плейсхолдерами title и text.

Запустить другой сниппет

return $modx->runSnippet('year', [
    'start' => '2022',
]);

В стандартный PHP-метод передается имя year без восклицательного знака. Здесь используется API, а не запись тега MODX и не обертка microMODX из Fenom.

При выполнении runSnippet код вызываемого сниппета запускается. Но готовый результат внешней страницы все равно может участвовать в ее кешировании.

Получить поле текущего ресурса

if (!$modx->resource) {
    return '';
}

return (string) $modx->resource->get('pagetitle');

Обработка результата зависит от места вывода. Например, при размещении текста в HTML можно применить htmlent к вызову этого сниппета.

Получить TV текущего ресурса

if (!$modx->resource) {
    return '';
}

return (string) $modx->resource->getTVValue('img');

img заменяется реальным именем дополнительного поля. Результат зависит от значения TV и его настроек вывода. В нашем учебном поле изображения это используется для получения его значения, а не для автоматического создания всего HTML-блока.

Для другого ресурса сначала нужно получить соответствующий объект ресурса и проверить, что он существует. Не подставляйте случайный ID из чужого примера.

Если первый сниппет не работает

Проблема Что проверить
Вместо года пустое место Имя year, сохранение кода и наличие return.
Параметр не учитывается Установлена ли новая версия кода year, совпадает ли имя start и правильно ли записан вызов.
Остается старый результат Кешируемый вызов, кеш ресурса и другие уровни кеширования.
На странице виден Fenom-код Его обработку в месте вызова. Для текущего упражнения используйте стандартный тег.
Белая страница или ошибка PHP Актуальную запись журнала MODX, кавычки, точки с запятой и сохраненную версию кода.

Выполняемый сниппет — серверный код. Используйте доверенные источники и проверяйте изменения на учебном сайте или копии проекта.

Нейросеть может помочь составить небольшой пример или объяснить ошибку, но ее ответ не заменяет проверку кода, параметров и результата. Не передавайте ей пароли и другие секреты проекта.

Подборка полезных мини сниппетов для разработчиков

Каждый пример сохраняем в отдельном сниппете с указанным именем. В редактор MODX вставляем тело PHP-кода без открывающего и закрывающего тегов. Сначала проверяем пример на учебном сайте или копии проекта.

Стандартные вызовы работают без Fenom. Альтернативные записи Fenom используем только там, где его обработка уже настроена. Параметры из примеров задает разработчик: не передавайте в них произвольные значения из GET или POST.

Форматирование чисел и цен: numformat

Сниппет разделяет тысячи обычными пробелами. Целое число выводит без дробной части, дробное по умолчанию округляет до двух знаков. Поддерживаются точка или запятая в качестве десятичного разделителя, обычные, неразрывные и узкие неразрывные пробелы между разрядами.

$value = $modx->getOption('input', $scriptProperties, '');
if (!is_scalar($value) || is_bool($value)) {
    return '';
}
$text = trim(str_replace(["\xC2\xA0", "\xE2\x80\xAF", ' '], '', (string) $value));
$text = str_replace(',', '.', $text);
if (!preg_match('/^[+-]?(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)$/D', $text)) {
    return '';
}
$number = (float) $text;
if (!is_finite($number)) {
    return '';
}
$decimals = $modx->getOption('decimals', $scriptProperties, 'auto');
if ($decimals === 'auto') {
    $digits = floor($number) == $number ? 0 : 2;
} elseif (is_scalar($decimals) && preg_match('/^[0-6]$/D', (string) $decimals)) {
    $digits = (int) $decimals;
} else {
    return '';
}
return number_format($number, $digits, '.', ' ');

Создайте numformat, сохраните код и проверьте прямой вызов:

[[numformat? &input=`33 354 567,5`]]

Результат: 33 354 567.50. Пустая или неподходящая строка возвращает пустой результат, а не цену 0.

Для TV с реальным именем price:

[[*price:numformat]]

Чтобы всегда выводить две цифры после точки или, наоборот, округлять до целого, задайте decimals:

[[numformat? &input=`1500` &decimals=`2`]]
[[numformat? &input=`1500.75` &decimals=`0`]]

Получится 1 500.00 и 1 501. Параметр decimals принимает auto или целое число от 0 до 6. Запись 1,234 здесь означает десятичное число, а не американскую группировку тысяч; значения с валютой, двумя десятичными разделителями или экспонентой не принимаются.

Fenom — вызов того же сниппета:

{'numformat' | snippet : ['input' => '12500,5', 'decimals' => 2]}

Это форматирование для вывода, не способ денежных расчетов. Для сумм в учете нужно отдельно выбрать подходящее представление и правила округления.

Удаление последнего символа: last

last удаляет один последний символ строки. Например, подходит для заранее известной последовательности ID, заканчивающейся запятой.

return mb_substr($input, 0, -1);

Требуется расширение mbstring. Внутренняя кодировка mbstring должна соответствовать кодировке строки; в нашем примере это UTF-8.

[[last? &input=`33,35,45,67,`]]
[[*resources:last]]

Первый вызов возвращает 33,35,45,67. Второй подходит только при наличии вашего TV resources. Сниппет не проверяет запятую: если ее нет, он все равно удалит последний символ. Не используйте его как универсальную очистку списка ID.

{'last' | snippet : ['input' => '33,35,45,67,']}

Удаление разделителей из телефона: phone

phone удаляет обычные пробелы, дефисы и круглые скобки из заранее подготовленного номера.

$p1 = array(" ", "-", "(", ")");
$p2 = array("", "", "", "");
return str_replace($p1, $p2, $input);
[[phone? &input=`8 (927) 778-45-45`]]

Результат: 89277784545. Если номер хранится в настройке phone, ссылка выглядит так:

<a href="tel:[[++phone:phone]]">[[++phone]]</a>

Настройка должна существовать. Например, ее можно создать через ClientConfig. Код не проверяет международный формат и добавочные номера, не является валидатором формы.

Fenom позволяет подготовить такой номер без отдельного сниппета:

<a href="tel:{$_modx->config.phone | preg_replace : '/[^0-9+]/' : ''}">{$_modx->config.phone}</a>

Этот вариант оставляет только цифры и плюс, поэтому удаляет больше видов разделителей, чем приведенный PHP-код. Оба примера рассчитаны на доверенное значение настройки, а не на произвольный пользовательский ввод.

Склонение слов после целого числа: plural

Сниппет возвращает нужную форму слова: 1 штука, 2 штуки, 5 штук. Само число выводится отдельно. Дробные значения не обрабатываются.

$value = $modx->getOption('n', $scriptProperties, '');
if (!is_scalar($value) || is_bool($value)) {
    return '';
}
$text = trim((string) $value);
if (!preg_match('/^[+-]?[0-9]+$/D', $text)) {
    return '';
}
$forms = [];
foreach (['w1' => 'штука', 'w2' => 'штуки', 'w3' => 'штук'] as $key => $default) {
    $word = $modx->getOption($key, $scriptProperties, $default);
    if (!is_string($word)) {
        return '';
    }
    $forms[$key] = $word;
}
$digits = ltrim($text, '+-');
$lastTwo = (int) substr($digits, -2);
$last = $lastTwo % 10;
if ($lastTwo >= 11 && $lastTwo <= 14) {
    return $forms['w3'];
}
if ($last === 1) {
    return $forms['w1'];
}
return $last >= 2 && $last <= 4 ? $forms['w2'] : $forms['w3'];
21 [[plural? &n=`21` &w1=`штука` &w2=`штуки` &w3=`штук`]]

Результат: 21 штука. Для нуля получим штук; для 11-14 также используется третья форма. Отрицательные целые числа выбирают форму по модулю числа.

21 {'plural' | snippet : ['n' => 21, 'w1' => 'штука', 'w2' => 'штуки', 'w3' => 'штук']}

Кавычки и амперсанды в HTML-атрибутах

Чтобы название с кавычками не ломало alt или content, экранируем значение для HTML. Заменять кавычки на елочки и удалять амперсанды для этого не нужно.

<img src="[[*img:htmlent]]" alt="[[*pagetitle:htmlent]]">

img здесь — имя вашего TV изображения. Замените его на действительное имя поля. Модификатор htmlent сохраняет смысл текста, но кодирует символы, значимые для HTML. Он не проверяет допустимость самого адреса картинки.

Для Fenom экранирование уместно именно в значении HTML-атрибута:

<img src="{$_modx->resource.img | escape}" alt="{$_modx->resource.pagetitle | escape}">

Не добавляйте escape ко всем значениям подряд: уже собранную доверенную HTML-разметку таким образом можно превратить в видимый текст. Этот пример не предназначен для JavaScript, CSS или проверки URL.

Совместимость со старым модификатором title

Если title уже используется во многих шаблонах, можно временно сохранить имя и заменить старую реализацию следующим кодом. Он больше не занимается типографикой и не удаляет символ &.

$value = $modx->getOption('input', $scriptProperties, '');
if (!is_scalar($value)) {
    return '';
}
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

Существующий вызов [[*pagetitle:title]] продолжит работать как HTML-экранирование. Не применяйте title и htmlent последовательно к одному значению. При следующих правках шаблонов переходите на штатный htmlent.

Передача значения из GET в форму: getGet

Пример сохраняет короткое текстовое значение из GET в сессии посетителя. Если параметра в новом запросе нет, возвращается сохраненное значение. Явно переданная пустая строка очищает его.

$var = $modx->getOption('var', $scriptProperties, 'item');
if (!is_string($var) || !preg_match('/^[A-Za-z][A-Za-z0-9_]{0,63}$/D', $var)) {
    return '';
}
if (!function_exists('session_status') || session_status() === PHP_SESSION_DISABLED) {
    $modx->log(1, '[getGet] PHP sessions are disabled.');
    return '';
}
if (session_status() === PHP_SESSION_NONE) {
    if (headers_sent() || !session_start()) {
        $modx->log(1, '[getGet] Cannot start a session.');
        return '';
    }
}
$context = $modx->context ? (string) $modx->context->get('key') : 'web';
$bucket = 'wr_getGet_' . $context;
if (!isset($_SESSION[$bucket]) || !is_array($_SESSION[$bucket])) {
    $_SESSION[$bucket] = [];
}
if (array_key_exists($var, $_GET)) {
    if (!is_string($_GET[$var])) {
        return '';
    }
    $value = trim($_GET[$var]);
    if (!preg_match('/^[^\x00-\x1F\x7F]{0,200}$/uD', $value)) {
        return '';
    }
    if ($value === '') {
        unset($_SESSION[$bucket][$var]);
        return '';
    }
    $_SESSION[$bucket][$var] = $value;
}
$value = isset($_SESSION[$bucket][$var]) && is_string($_SESSION[$bucket][$var])
    ? $_SESSION[$bucket][$var] : '';
$value = htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
return str_replace(['[', ']', '{', '}'], ['&#91;', '&#93;', '&#123;', '&#125;'], $value);

Создайте сниппет getGet. Для проверки откройте страницу формы с параметром ?item=Тест. В нужном месте формы вставьте некешируемый вызов:

<input type="hidden" name="item" value="[[!getGet? &var=`item`]]">

Fenom — для сравнения:

<input type="hidden" name="item" value="{'!getGet' | snippet : ['var' => 'item']}">

Результат уже подготовлен для HTML-текста или значения атрибута в кавычках. Дополнительный htmlent или escape к нему не добавляем. В сессии хранится исходный текст, экранирование выполняется только перед возвратом результата. Скобки тегов также кодируются, чтобы пользовательское значение не стало вызовом MODX или Fenom.

В ссылке на форму значение item нужно URL-кодировать, а не просто приклеивать название товара к адресу. Например, при включенных ЧПУ, если ваша форма действительно доступна по адресу /order/:

<a href="/order/?item=[[*pagetitle:urlencode]]">Заказать</a>

Путь /order/ замените адресом своей формы. Этот способ добавления параметра рассчитан на адрес без уже существующей строки запроса. Название с символом & должно передаваться одним параметром, а не разбивать ссылку на несколько параметров.

Принимаются строки до 200 символов без управляющих символов. Массив вроде ?item[]=Тест и неподходящие значения возвращают пустой результат. Сохраненные значения разделены по контекстам; для нескольких форм в одном контексте задавайте разные var, например order_item и callback_item.

Не заменяйте GET на REQUEST без отдельной задачи. Сниппет не отправляет форму, не проверяет товар и не защищает заказ от подмены. Цену и существование товара сервер должен проверять по ID в каталоге, а не по скрытому полю.

Данные сессии зависят от cookie и срока ее жизни. При неудачной валидации значение POST должен восстанавливать обработчик формы, иначе getGet может снова показать прежнее значение GET. Персональный результат нельзя отдавать через общий HTML-кеш сервера или CDN. Если сессия не запускается, проверьте журнал MODX и вывод до отправки заголовков.

Оценка времени чтения: readtime

Считает слова с кириллицей, позволяет выбрать поля и TV, учитывать изображения и вернуть число минут, слов или подпись с правильным склонением.

$options = $modx->getOption('options', $scriptProperties, 200);
if (!is_scalar($options) || !preg_match('/^[0-9]{1,4}$/D', (string) $options)) {
    return '';
}
$speed = (int) $options;
if ($speed > 2000) {
    return '';
}
$seconds = $modx->getOption('imageSeconds', $scriptProperties, 12);
if (!is_scalar($seconds) || !preg_match('/^[0-9]{1,3}$/D', (string) $seconds)
    || (int) $seconds > 300) {
    return '';
}
$mode = $modx->getOption('mode', $scriptProperties, 'label');
if (!in_array($mode, ['label', 'minutes', 'words'], true)) {
    return '';
}
$html = '';
$extraImages = 0;
if (array_key_exists('input', $scriptProperties)) {
    if (!is_string($scriptProperties['input'])) {
        return '';
    }
    $html = $scriptProperties['input'];
} else {
    if (!$modx->resource) {
        return '';
    }
    $lists = [];
    foreach (['fields' => 'content', 'textTvs' => '', 'imageTvs' => ''] as $key => $default) {
        $list = $modx->getOption($key, $scriptProperties, $default);
        if (!is_string($list)) {
            return '';
        }
        $lists[$key] = array_unique(array_filter(array_map('trim', explode(',', $list)), 'strlen'));
    }
    foreach ($lists['fields'] as $field) {
        $value = $modx->resource->get($field);
        if (is_scalar($value)) {
            $html .= ' ' . (string) $value;
        }
    }
    foreach ($lists['textTvs'] as $tv) {
        $value = $modx->resource->getTVValue($tv);
        if (is_scalar($value)) {
            $html .= ' ' . (string) $value;
        }
    }
    foreach ($lists['imageTvs'] as $tv) {
        $value = $modx->resource->getTVValue($tv);
        if (is_string($value) && trim($value) !== '') {
            ++$extraImages;
        }
    }
}
$html = preg_replace('~<!--.*?-->|<(script|style)\b[^>]*>.*?</\1\s*>~is', ' ', $html);
if ($html === null) {
    return '';
}
$imageCount = preg_match_all('~<img\b[^>]*>~i', $html);
$text = preg_replace('~<[^>]*>~s', ' ', $html);
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$words = preg_match_all("~[\\p{L}\\p{N}]+(?:['’-][\\p{L}\\p{N}]+)*~u", $text);
if ($words === false) {
    return '';
}
if ($speed === 0 || $mode === 'words') {
    return (string) $words;
}
$minutes = (int) ceil(($words / $speed) + (($imageCount + $extraImages) * (int) $seconds / 60));
if ($mode === 'minutes') {
    return (string) $minutes;
}
$lastTwo = $minutes % 100;
$last = $minutes % 10;
$form = $lastTwo >= 11 && $lastTwo <= 14 ? 'минут'
    : ($last === 1 ? 'минута' : ($last >= 2 && $last <= 4 ? 'минуты' : 'минут'));
return $minutes . ' ' . $form;

По умолчанию считаем поле content: 200 слов в минуту и 12 секунд на изображение. Это выбранные настройки оценки, а не измерение скорости конкретного читателя.

Время чтения: [[readtime]]

Если статья складывается из анонса, текста и отдельного изображения, перечислите их:

Пример составной статьи

[[readtime?
    &fields=`introtext,content`
    &imageTvs=`img`
    &options=`200`
    &imageSeconds=`12`
]]

Для текстовых TV используйте textTvs, например &textTvs=`article_lead,article_extra`. Перечисляйте только реально выводимые поля. Если анонс повторяет начало статьи, не включайте его второй раз. Каждое непустое TV из imageTvs считается одной отдельной картинкой: не указывайте там изображение, уже присутствующее в HTML content.

Fenom — тот же набор параметров:

{'readtime' | snippet : [
    'fields' => 'introtext,content',
    'imageTvs' => 'img',
    'options' => 200,
    'imageSeconds' => 12
]}

Если нужно только число минут:

[[readtime? &mode=`minutes`]]

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

<p>Время чтения: [[readtime? &input=`[[*content]]` &mode=`minutes`]] мин.</p>

Для подсчета слов задайте mode=`words` или options=`0`. Старые вызовы [[*content:readtime]] нужно проверить: теперь результат по умолчанию содержит слово минута/минуты/минут. Для короткого вывода только поля проще использовать прямой вызов выше.

Суммарное время текста и изображений округляется вверх один раз. Пустой материал без изображений дает 0 минут. Сниппет не исполняет вложенные теги и не собирает окончательную страницу: динамически подгружаемые блоки, JSON с блоками, MIGX и PageBlocks автоматически не разбираются. Для такого содержания нужен отдельный адаптер к фактической структуре данных.

Хлебные крошки в JSON-LD: breadcrumbJsonLd

Создайте breadcrumbJsonLd. В примере цепочка идет от главной страницы контекста через родителей текущего ресурса. Названия и адреса формируются как данные, а не склеиваются вручную в JSON.

if (!$modx->resource || !$modx->context) {
    return '';
}
$class = class_exists('MODX\Revolution\modResource')
    ? 'MODX\Revolution\modResource' : 'modResource';
$context = (string) $modx->resource->get('context_key');
$home = (int) $modx->getOption('site_start');
$currentId = (int) $modx->resource->get('id');
if ($currentId === $home) {
    return '';
}
$isPublic = static function ($resource) {
    $now = time();
    return $resource && $resource->get('published') && !$resource->get('deleted')
        && !$resource->get('privateweb')
        && (!(int) $resource->get('pub_date') || (int) $resource->get('pub_date') <= $now)
        && (!(int) $resource->get('unpub_date') || (int) $resource->get('unpub_date') > $now);
};
$chain = [];
$seen = [];
$resource = $modx->resource;
for ($depth = 0; $resource && $depth < 64; ++$depth) {
    $id = (int) $resource->get('id');
    if (isset($seen[$id]) || !$isPublic($resource)
        || (string) $resource->get('context_key') !== $context) {
        return '';
    }
    $seen[$id] = true;
    $chain[] = $resource;
    $parent = (int) $resource->get('parent');
    if ($parent === 0) {
        break;
    }
    $resource = $modx->getObject($class, $parent);
    if (!$resource) {
        return '';
    }
}
if (!$chain || (int) end($chain)->get('parent') !== 0) {
    return '';
}
$chain = array_reverse($chain);
if (!isset($seen[$home])) {
    $homeResource = $modx->getObject($class, $home);
    if (!$isPublic($homeResource) || (string) $homeResource->get('context_key') !== $context) {
        return '';
    }
    array_unshift($chain, $homeResource);
}
$items = [];
foreach ($chain as $resource) {
    $name = trim(strip_tags(html_entity_decode(
        (string) ($resource->get('menutitle') ?: $resource->get('pagetitle')),
        ENT_QUOTES | ENT_HTML5, 'UTF-8'
    )));
    $url = $modx->makeUrl((int) $resource->get('id'), $context, '', 'full');
    if ($name === '' || !is_string($url) || !preg_match('~^https?://~i', $url)) {
        return '';
    }
    $items[] = ['@type' => 'ListItem', 'position' => count($items) + 1,
        'name' => $name, 'item' => $url];
}
if (count($items) < 2) {
    return '';
}
$json = json_encode(['@context' => 'https://schema.org', '@type' => 'BreadcrumbList',
    'itemListElement' => $items], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
    | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
return $json === false ? '' : '<script type="application/ld+json">' . $json . '</script>';

Разместите один вызов в head шаблона:

[[breadcrumbJsonLd]]
{'breadcrumbJsonLd' | snippet}

Две записи выше — альтернативы. Не вставляйте обе одновременно. На главной сниппет ничего не выводит. Также он возвращает пустой результат при недоступном элементе цепочки или неполной структуре.

Используем menutitle, а если он пустой — pagetitle. Каждый адрес получается через makeUrl с контекстом ресурса. Если у проекта отдельные canonical, пользовательская маршрутизация или другая логика хлебных крошек, алгоритм нужно согласовать с ними. Исключение hidemenu здесь не применяется автоматически.

Цепочка должна соответствовать реальной навигации. Не подключайте второй BreadcrumbList поверх разметки, уже создаваемой SEO-компонентом. После вставки откройте исходный код и проверьте JSON, порядок элементов, названия и полные адреса. Корректная разметка не гарантирует расширенное отображение в поиске.

Одинаковый URI в разных контекстах не доказывает, что страницы являются переводами. Поэтому вместо обхода всех контекстов задаем соответствия явно.

Создайте текстовое TV translations без преобразования JSON при выводе, привяжите к нужным шаблонам. Во всех переводах одной страницы сохраните одинаковую карту. Например, если реальные ID русской и английской страниц — 101 и 202:

{"ru":101,"en":202,"x-default":101}

101 и 202 — только демонстрационные номера, замените их настоящими ID. Для другой группы переводов нужна другая карта. x-default необязателен и задает страницу для неподходящих языковых настроек; это не команда автоматически отправлять все отсутствующие переводы на главную.

Создайте сниппет hreflangLinks:

if (!$modx->resource) {
    return '';
}
$tv = $modx->getOption('tv', $scriptProperties, 'translations');
if (!is_string($tv) || $tv === '') {
    return '';
}
$readMap = static function ($resource) use ($tv) {
    $raw = $resource->getTVValue($tv);
    if (!is_string($raw)) {
        return false;
    }
    $map = json_decode($raw, true);
    if (!is_array($map) || count($map) < 2 || count($map) > 50) {
        return false;
    }
    $result = [];
    foreach ($map as $language => $id) {
        if (!is_string($language) || ($language !== 'x-default'
            && !preg_match('/^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/iD', $language))) {
            return false;
        }
        if ((!is_int($id) && !is_string($id)) || !preg_match('/^[1-9][0-9]*$/D', (string) $id)
            || (float) $id > PHP_INT_MAX) {
            return false;
        }
        $key = strtolower($language);
        if (isset($result[$key])) {
            return false;
        }
        $result[$key] = (int) $id;
    }
    ksort($result);
    return $result;
};
$map = $readMap($modx->resource);
if ($map === false) {
    return '';
}
$languages = $map;
unset($languages['x-default']);
if (count($languages) < 2 || !in_array((int) $modx->resource->get('id'), $languages, true)) {
    return '';
}
$class = class_exists('MODX\Revolution\modResource')
    ? 'MODX\Revolution\modResource' : 'modResource';
$resources = [];
$now = time();
foreach (array_unique(array_values($map)) as $id) {
    $resource = $modx->getObject($class, $id);
    if (!$resource || !$resource->get('published') || $resource->get('deleted')
        || $resource->get('privateweb')
        || ((int) $resource->get('pub_date') > $now)
        || ((int) $resource->get('unpub_date') && (int) $resource->get('unpub_date') <= $now)) {
        return '';
    }
    if (in_array($id, $languages, true) && $readMap($resource) !== $map) {
        return '';
    }
    $resources[$id] = $resource;
}
$output = [];
foreach ($map as $language => $id) {
    $context = (string) $resources[$id]->get('context_key');
    $url = $modx->makeUrl($id, $context, '', 'full');
    if (!is_string($url) || !preg_match('~^https?://~i', $url)) {
        return '';
    }
    $output[] = '<link rel="alternate" hreflang="'
        . htmlspecialchars($language, ENT_QUOTES, 'UTF-8') . '" href="'
        . htmlspecialchars($url, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '">';
}
return implode(PHP_EOL, $output);

Подключите в head:

[[hreflangLinks]]
{'hreflangLinks' | snippet}

Сниппет проверяет наличие текущей страницы в языковой группе, опубликованность ресурсов и одинаковую карту на каждой языковой версии. Если карта неполная или взаимные соответствия не совпадают, результат будет пустым. Поэтому сначала заполните TV на всех переводах, затем очистите кеш и проверяйте вывод.

Адрес получается через makeUrl для контекста каждого ресурса. В контекстах должны быть правильно настроены site_url и маршрутизация. Проверка кода языка в примере проверяет форму записи, а не существование всех кодов ISO: используйте действительные ru, en, en-GB и другие подходящие значения.

Проверьте, что страницы действительно доступны по HTTP, не закрыты noindex, не перенаправляют на другие адреса и ссылаются друг на друга. Проверка published в базе не заменяет такую проверку. Если сайт использует Babel или другой механизм связей переводов, лучше брать соответствия из него, а не поддерживать параллельную ручную карту.

Это замена алгоритма прежнего примера, не совместимый перенос его параметра x-default=`web`. Старый вызов удаляем из head и заменяем новым после заполнения TV. Само имя старого сниппета можно сохранить вместо hreflangLinks, если вы одновременно замените его код и все параметры вызова.

Размер и расширение локального файла: FileSize

По умолчанию разрешены только существующие файлы внутри assets/files/. Путь input задается относительно корня MODX, без начального слеша и без полного URL. HTTP-загрузка и чтение файлов вне разрешенного каталога не выполняются.

$input = $modx->getOption('input', $scriptProperties, '');
$directory = $modx->getOption('directory', $scriptProperties, 'assets/files/');
if (!is_string($input) || !is_string($directory) || $input === '' || $directory === '') {
    return '';
}
$input = str_replace('\\', '/', $input);
$directory = str_replace('\\', '/', $directory);
if (strpos($input, "\0") !== false || strpos($directory, "\0") !== false
    || preg_match('~^[a-z][a-z0-9+.-]*:|^/|[?#]~i', $input)
    || preg_match('~^[a-z][a-z0-9+.-]*:|^/|[?#]~i', $directory)) {
    return '';
}
$base = realpath(MODX_BASE_PATH);
$root = realpath(MODX_BASE_PATH . $directory);
$file = realpath(MODX_BASE_PATH . $input);
$inside = static function ($path, $parent) {
    $path = str_replace('\\', '/', $path);
    $parent = rtrim(str_replace('\\', '/', $parent), '/') . '/';
    return DIRECTORY_SEPARATOR === '\\'
        ? strncasecmp($path, $parent, strlen($parent)) === 0
        : strncmp($path, $parent, strlen($parent)) === 0;
};
if ($base === false || $root === false || $file === false || !is_dir($root)
    || !$inside($root, $base) || !$inside($file, $root) || !is_file($file) || !is_readable($file)) {
    return '';
}
$getExtension = $modx->getOption('get_ext', $scriptProperties, 0);
if ($getExtension === 1 || $getExtension === '1' || $getExtension === true) {
    return strtolower(pathinfo($file, PATHINFO_EXTENSION));
}
clearstatcache(true, $file);
$bytes = filesize($file);
if ($bytes === false) {
    return '';
}
$units = ['b' => [1, 'байт'], 'kb' => [1024, 'КиБ'],
    'mb' => [1048576, 'МиБ'], 'gb' => [1073741824, 'ГиБ']];
$unit = $modx->getOption('unit', $scriptProperties, 'auto');
if (!is_string($unit)) {
    return '';
}
$unit = strtolower($unit);
if ($unit === 'auto') {
    $unit = $bytes >= 1073741824 ? 'gb' : ($bytes >= 1048576 ? 'mb' : ($bytes >= 1024 ? 'kb' : 'b'));
}
if (!isset($units[$unit])) {
    return '';
}
$value = $bytes / $units[$unit][0];
$formatted = $unit === 'b' ? (string) $bytes : rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
return $formatted . ' ' . $units[$unit][1];
[[FileSize? &input=`assets/files/catalog.pdf`]]
[[FileSize? &input=`assets/files/catalog.pdf` &unit=`kb`]]
[[FileSize? &input=`assets/files/catalog.pdf` &get_ext=`1`]]

Замените путь существующим файлом. Для TV file_catalog:

[[*file_catalog:FileSize]]
[[FileSize? &input=`[[*file_catalog]]` &get_ext=`1`]]

TV должно возвращать путь, а не готовую ссылку или тег. Запись FileSize?&get_ext в роли модификатора не используем: параметры удобнее передать прямым вызовом.

{'FileSize' | snippet : ['input' => 'assets/files/catalog.pdf', 'unit' => 'auto']}

Доступные unit: auto, b, kb, mb, gb. Делители двоичные: 1024 байта = 1 КиБ, 1024 КиБ = 1 МиБ. Отсутствующий или недоступный файл дает пустой результат; существующий пустой файл — 0 байт.

Для другого публичного каталога явно задайте directory, например assets/downloads/. Сам каталог должен существовать внутри корня установки. Не передавайте directory из пользовательского ввода и не расширяйте разрешения до служебных каталогов.

Если файл заменяется по прежнему адресу и размер должен обновляться сразу при запросе MODX, используйте [[!FileSize?…]]. Кешируемый вызов подходит, если при замене файла вы обновляете кеш соответствующих страниц. Никакой из вариантов не отключает внешний HTML-кеш.

FaviconGenerator — генерация иконок внутри MODX

Сохраняем генератор на сайте: он берет одно локальное изображение, создает комплект файлов и возвращает теги для head. Пользователя не нужно отправлять в сторонний сервис. Для обработки размеров используется установленный сниппет phpthumbof или совместимый по параметрам обработчик.

Подготовьте квадратный PNG или JPEG размером от 512 до 4096 пикселей и не больше 16 МиБ. Например, загрузите assets/images/favicon.png. Каталог assets должен быть доступен PHP для записи, глобальные права 777 для этого не нужны.

Создайте FaviconGenerator и вставьте полный код:

Полный PHP-код FaviconGenerator
$fail = static function ($message) use ($modx) {
    $modx->log(1, '[FaviconGenerator] ' . $message);
    return '';
};
$inside = static function ($path, $parent) {
    $path = str_replace('\\', '/', $path);
    $parent = rtrim(str_replace('\\', '/', $parent), '/') . '/';
    return DIRECTORY_SEPARATOR === '\\'
        ? strncasecmp($path, $parent, strlen($parent)) === 0
        : strncmp($path, $parent, strlen($parent)) === 0;
};
$properties = [];
foreach (['image' => '', 'snippet' => 'phpthumbof', 'backgroundColor' => '', 'tabColor' => ''] as $key => $default) {
    $value = $modx->getOption($key, $scriptProperties, $default);
    if (!is_string($value)) {
        return $fail('Invalid parameter: ' . $key);
    }
    $properties[$key] = trim($value);
}
foreach (['backgroundColor', 'tabColor'] as $key) {
    $color = ltrim($properties[$key], '#');
    if ($color !== '' && !preg_match('/^(?:[a-f0-9]{3}|[a-f0-9]{6})$/iD', $color)) {
        return $fail('Invalid color: ' . $key);
    }
    if (strlen($color) === 3) {
        $color = $color[0] . $color[0] . $color[1] . $color[1] . $color[2] . $color[2];
    }
    $properties[$key] = strtolower($color);
}
$icoValue = $modx->getOption('ico', $scriptProperties, 0);
if (!in_array($icoValue, [0, 1, '0', '1', false, true], true)) {
    return $fail('ico must be 0 or 1.');
}
$withIco = (bool) $icoValue;
$image = str_replace('\\', '/', $properties['image']);
if ($image === '' || strpos($image, "\0") !== false
    || preg_match('~^[a-z][a-z0-9+.-]*:|^/|[?#]~i', $image)) {
    return $fail('image must be a local path relative to MODX_BASE_PATH.');
}
$base = realpath(MODX_BASE_PATH);
$assets = realpath(MODX_ASSETS_PATH);
$source = realpath(MODX_BASE_PATH . $image);
if ($base === false || $assets === false || $source === false || !$inside($source, $base)
    || !is_file($source) || !is_readable($source) || filesize($source) > 16777216) {
    return $fail('Source image is unavailable or exceeds 16 MiB.');
}
$info = @getimagesize($source);
if (!$info || !in_array($info[2], [IMAGETYPE_PNG, IMAGETYPE_JPEG], true)
    || $info[0] !== $info[1] || $info[0] < 512 || $info[0] > 4096) {
    return $fail('Use a square PNG/JPEG from 512 to 4096 px.');
}
$processor = $properties['snippet'];
if (!preg_match('/^[A-Za-z0-9_.-]{1,80}$/D', $processor)) {
    return $fail('Invalid processor name.');
}
$snippetClass = class_exists('MODX\Revolution\modSnippet') ? 'MODX\Revolution\modSnippet' : 'modSnippet';
if (!$modx->getObject($snippetClass, ['name' => $processor])) {
    return $fail('Processor snippet not found: ' . $processor);
}
$siteUrl = (string) $modx->getOption('site_url');
$siteParts = parse_url($siteUrl);
if (!is_array($siteParts) || empty($siteParts['host'])
    || !isset($siteParts['scheme']) || !in_array(strtolower($siteParts['scheme']), ['http', 'https'], true)) {
    return $fail('Configure an absolute HTTP(S) site_url.');
}
$start = isset($siteParts['path']) ? $siteParts['path'] : '/';
$start = rtrim($start, '/') . '/';
$name = (string) $modx->getOption('site_name', null, 'Website');
$context = $modx->context ? (string) $modx->context->get('key') : 'web';
$relative = 'generated/favicons/' . substr(hash('sha256', $context . '|' . $siteUrl), 0, 16) . '/';
$directory = rtrim(MODX_ASSETS_PATH, '/\\') . '/' . $relative;
if (!is_dir($directory) && !@mkdir($directory, 0755, true) && !is_dir($directory)) {
    return $fail('Cannot create the output directory.');
}
$directory = realpath($directory);
if ($directory === false || !$inside($directory, $assets) || !is_writable($directory)) {
    return $fail('Output directory is not writable or is outside assets.');
}
$directory .= DIRECTORY_SEPARATOR;
$url = rtrim(MODX_ASSETS_URL, '/') . '/' . $relative;
$files = [16 => 'favicon-16x16.png', 32 => 'favicon-32x32.png',
    180 => 'apple-touch-icon.png', 192 => 'android-chrome-192x192.png',
    512 => 'android-chrome-512x512.png'];
$required = array_values($files);
$required[] = 'site.webmanifest';
if ($withIco) {
    $required[] = 'favicon.ico';
}
$digest = hash_file('sha256', $source);
if ($digest === false) {
    return $fail('Cannot read the source image.');
}
$signatureData = json_encode(['version' => 2, 'source' => $source, 'digest' => $digest,
    'properties' => $properties, 'ico' => $withIco, 'name' => $name, 'start' => $start,
    'url' => $url], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($signatureData === false) {
    return $fail('Invalid UTF-8 in configuration.');
}
$signature = hash('sha256', $signatureData);
$lock = @fopen($directory . '.generation.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX)) {
    if (is_resource($lock)) {
        fclose($lock);
    }
    return $fail('Cannot acquire the generation lock.');
}
$write = static function ($name, $data) use ($directory) {
    $temporary = tempnam($directory, '.tmp-');
    if ($temporary === false) {
        throw new RuntimeException('Cannot create a temporary file.');
    }
    try {
        if (file_put_contents($temporary, $data) !== strlen($data)) {
            throw new RuntimeException('Cannot write: ' . $name);
        }
        if (!chmod($temporary, 0644) || !rename($temporary, $directory . $name)) {
            throw new RuntimeException('Cannot publish: ' . $name);
        }
    } finally {
        if (is_file($temporary)) {
            unlink($temporary);
        }
    }
};
$resolve = static function ($result) use ($base, $inside, $siteParts, $modx) {
    if (!is_string($result) || trim($result) === '') {
        throw new RuntimeException('The image processor returned no URL.');
    }
    $parts = parse_url(trim(html_entity_decode($result, ENT_QUOTES, 'UTF-8')));
    if (!is_array($parts) || isset($parts['query']) || isset($parts['fragment'])
        || (isset($parts['scheme']) && !in_array(strtolower($parts['scheme']), ['http', 'https'], true))
        || (isset($parts['host']) && (strcasecmp($parts['host'], $siteParts['host']) !== 0
            || ($parts['port'] ?? null) !== ($siteParts['port'] ?? null)))) {
        throw new RuntimeException('Only local static thumbnail files are supported.');
    }
    $path = isset($parts['path']) ? rawurldecode($parts['path']) : '';
    $baseUrl = (string) $modx->getOption('base_url', null, MODX_BASE_URL);
    if ($path === '' || strpos($path, "\0") !== false || strpos($path, '\\') !== false) {
        throw new RuntimeException('Invalid thumbnail path.');
    }
    if ($path[0] === '/') {
        if ($baseUrl === '' || strpos($path, $baseUrl) !== 0) {
            throw new RuntimeException('Thumbnail is outside base_url.');
        }
        $path = substr($path, strlen($baseUrl));
    }
    $file = realpath(MODX_BASE_PATH . $path);
    if ($file === false || !$inside($file, $base) || !is_file($file) || !is_readable($file)
        || filesize($file) > 16777216) {
        throw new RuntimeException('Thumbnail file is unavailable.');
    }
    return $file;
};
try {
    $ready = is_file($directory . '.fingerprint')
        && file_get_contents($directory . '.fingerprint') === $signature;
    foreach ($required as $file) {
        $ready = $ready && is_file($directory . $file) && filesize($directory . $file) > 0;
    }
    if (!$ready) {
        foreach ($files as $size => $filename) {
            $options = 'w=' . $size . '&h=' . $size . '&zc=1&f=png';
            if ($properties['backgroundColor'] !== '') {
                $options .= '&bg=' . $properties['backgroundColor'];
            }
            $result = $modx->runSnippet($processor, ['input' => $image, 'options' => $options]);
            $file = $resolve($result);
            $check = @getimagesize($file);
            if (!$check || $check[2] !== IMAGETYPE_PNG || $check[0] !== $size || $check[1] !== $size) {
                throw new RuntimeException('Processor did not produce a ' . $size . ' px PNG.');
            }
            $data = file_get_contents($file);
            if ($data === false) {
                throw new RuntimeException('Cannot read a PNG thumbnail.');
            }
            $write($filename, $data);
        }
        if ($withIco) {
            $options = 'w=32&h=32&zc=1&f=ico';
            if ($properties['backgroundColor'] !== '') {
                $options .= '&bg=' . $properties['backgroundColor'];
            }
            $file = $resolve($modx->runSnippet($processor, ['input' => $image, 'options' => $options]));
            $data = file_get_contents($file);
            if ($data === false || strlen($data) < 22 || substr($data, 0, 4) !== "\x00\x00\x01\x00") {
                throw new RuntimeException('ICO generation is not supported by the configured processor.');
            }
            $write('favicon.ico', $data);
        }
        $manifest = ['name' => $name, 'short_name' => $name,
            'icons' => [
                ['src' => $url . 'android-chrome-192x192.png', 'sizes' => '192x192', 'type' => 'image/png', 'purpose' => 'any'],
                ['src' => $url . 'android-chrome-512x512.png', 'sizes' => '512x512', 'type' => 'image/png', 'purpose' => 'any'],
            ], 'start_url' => $start, 'scope' => $start, 'display' => 'browser'];
        if ($properties['tabColor'] !== '') {
            $manifest['theme_color'] = '#' . $properties['tabColor'];
        }
        if ($properties['backgroundColor'] !== '') {
            $manifest['background_color'] = '#' . $properties['backgroundColor'];
        }
        $json = json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
        if ($json === false) {
            throw new RuntimeException('Cannot encode the manifest.');
        }
        $write('site.webmanifest', $json);
        $write('.fingerprint', $signature);
    }
} catch (Throwable $exception) {
    return $fail($exception->getMessage());
} finally {
    flock($lock, LOCK_UN);
    fclose($lock);
}
$escape = static function ($value) {
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
};
$output = [];
if ($withIco) {
    $output[] = '<link rel="icon" type="image/x-icon" href="' . $escape($url . 'favicon.ico') . '">';
}
foreach ([16, 32, 192] as $size) {
    $output[] = '<link rel="icon" type="image/png" sizes="' . $size . 'x' . $size
        . '" href="' . $escape($url . $files[$size]) . '">';
}
$output[] = '<link rel="apple-touch-icon" sizes="180x180" href="' . $escape($url . $files[180]) . '">';
$output[] = '<link rel="manifest" href="' . $escape($url . 'site.webmanifest') . '">';
if ($properties['tabColor'] !== '') {
    $output[] = '<meta name="theme-color" content="#' . $properties['tabColor'] . '">';
}
return implode(PHP_EOL, $output);

В head общего шаблона разместите кешируемый вызов, без восклицательного знака:

[[FaviconGenerator?
    &image=`assets/images/favicon.png`
    &backgroundColor=`ffffff`
    &tabColor=`ffffff`
    &ico=`1`
    &snippet=`phpthumbof`
]]

Fenom — альтернативная запись:

{'FaviconGenerator' | snippet : [
    'image' => 'assets/images/favicon.png',
    'backgroundColor' => 'ffffff',
    'tabColor' => 'ffffff',
    'ico' => 1,
    'snippet' => 'phpthumbof'
]}
Параметр По умолчанию Назначение
image Пусто Путь к локальному PNG/JPEG от корня MODX, без начального слеша.
backgroundColor Пусто Цвет фона: 3 или 6 шестнадцатеричных символов. Решетка допустима.
tabColor Пусто Цвет theme-color и theme_color в манифесте.
ico 0 1 — дополнительно создать favicon.ico через обработчик.
snippet phpthumbof Имя установленного обработчика с параметрами input и options.

Создаются PNG 16, 32, 180, 192 и 512 пикселей, site.webmanifest и, при ico=1, favicon.ico. ICO в этой версии запрашивается размером 32 × 32: создание многоразмерного ICO не заявляется.

Генератор сохраняет копии результатов в assets/generated/favicons/ в отдельном каталоге для контекста и site_url. Эти файлы не удаляются вместе с обычным кешем миниатюр. Адреса остаются прежними при замене логотипа; вручную редактировать имена файлов не нужно.

Кеш ресурса сохраняет возвращенные теги. При повторном выполнении PHP дополнительно сверяет отпечаток исходника и настроек и наличие файлов. Если комплект не изменился, обработчик изображений не вызывается. Блокировка защищает от одновременной генерации одного комплекта несколькими запросами.

Параметр snippet сохранен. Для pThumb можно использовать имя pthumb или phpthumbof, если оно зарегистрировано установленным пакетом. Это не обещание совместимости с любым обработчиком: он должен вернуть URL реального локального PNG нужного размера, а для ico=1 — настоящий ICO. Ответы с CDN, S3, PHP-обработчиками и строкой запроса эта версия не копирует.

Если выбранный обработчик не умеет ICO, генератор записывает ошибку в журнал MODX и не возвращает ложную ссылку на PNG с расширением .ico. Для полного комплекта с ico=1 сначала настройте поддержку ICO в используемом движке обработки изображений. Вызов с ico=0 создает только PNG и манифест.

Манифест получает site_name, стартовый путь контекста и display=browser. Наличие этого файла не превращает сайт в готовую PWA. purpose=maskable автоматически не добавляется.

Проверяем генерацию

  1. Сохраните сниппет и вызов, очистите кеш MODX и откройте страницу сайта.
  2. Проверьте журнал MODX: при ошибке ищите запись с префиксом [FaviconGenerator].
  3. Откройте исходный код страницы. На месте вызова должны быть link и meta, без повторного старого комплекта favicon.
  4. Откройте каждый выведенный адрес. PNG и ICO должны возвращать файлы, а не HTML-страницу или 404.
  5. Откройте site.webmanifest, проверьте JSON и оба src. Для него настройте MIME-тип application/manifest+json.
  6. Откройте вложенную страницу и проверьте те же адреса. Повторный запрос не должен менять дату файлов уже готового комплекта.

Если меняете исходник под прежним именем, очистите также кеш обработчика миниатюр: его собственная стратегия обновления независима от отпечатка генератора. Затем очистите кеш страниц MODX. Более простой вариант — загрузить новый исходник под новым именем и изменить image. Сами публичные адреса готового комплекта при этом сохранятся.

После ручного удаления готовых файлов нужно очистить кеш страниц MODX, иначе сохраненные теги не запустят генератор повторно. При изменении кода обработчика или генератора также обновите кеш миниатюр и удалите только файл .fingerprint нужного комплекта перед очисткой кеша MODX. Не удаляйте все assets.

Для проекта с CDN, несколькими серверами или нестандартным источником медиафайлов понадобится другая схема хранения. Здесь рассматривается локальная файловая система одной установки MODX. Подробнее о назначении иконок и форматах — в статье о favicon.

Следующий урок

Дальше разберем модификаторы MODX: научимся проверять значения, задавать условия, подставлять запасной текст и обрабатывать результат вывода тегов.

К подборке сниппетов можно возвращаться по мере необходимости. Для продолжения курса достаточно понимать, как создать элемент, передать ему параметры и использовать возвращаемый результат.

Повторное использование чанков MODX в шаблонах и карточках
← Предыдущая статьяЧанки MODX: создание, вызов и передача параметров
Следующая статья →Модификаторы MODX: условия и обработка значений
Условия и обработка значений модификаторами MODX
Поделиться с друзьями
Алексей

Веб-дизайнер и SEO оптимизатор. Занимаюсь созданием сайтов с 2010 года и их продвижение с 2012 года!

Оцените автора
( 5 оценок, среднее 4.2 из 5 )
Web-Revenue.ru