diff --git a/.github/logo/logo-dark.png b/.github/logo/logo-dark.png new file mode 100644 index 0000000..4dd6fb1 Binary files /dev/null and b/.github/logo/logo-dark.png differ diff --git a/.github/logo/logo-dark@2x.png b/.github/logo/logo-dark@2x.png new file mode 100644 index 0000000..54546bd Binary files /dev/null and b/.github/logo/logo-dark@2x.png differ diff --git a/.github/logo/logo.html b/.github/logo/logo.html new file mode 100644 index 0000000..a59ab84 --- /dev/null +++ b/.github/logo/logo.html @@ -0,0 +1,50 @@ + + + + + + + + + + + + diff --git a/.github/logo/logo.png b/.github/logo/logo.png new file mode 100644 index 0000000..0d1b2b4 Binary files /dev/null and b/.github/logo/logo.png differ diff --git a/.github/logo/logo@2x.png b/.github/logo/logo@2x.png new file mode 100644 index 0000000..22279fb Binary files /dev/null and b/.github/logo/logo@2x.png differ diff --git a/.github/logo/social.png b/.github/logo/social.png new file mode 100644 index 0000000..9569c6b Binary files /dev/null and b/.github/logo/social.png differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd02090..c82d717 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,9 @@ jobs: - name: Committed build matches src/ run: npm run build:check + - name: The build runs in every browser of browserslist (ES2019) + run: npm run check:syntax + scripts: name: Workflows and shell scripts runs-on: ubuntu-latest diff --git a/README.md b/README.md index 5200f7f..8fc2fdf 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,131 @@ -# FetchIt - -Компонент для MODX Revolution для отправки форм с помощью Fetch API. - -![Логотип FetchIt](https://github.com/GulomovCreative/FetchIt/blob/master/fetchit-logo.svg?raw=true&v=3) - -В CMS/CMF MODX Revolution есть компонент [FormIt](https://github.com/Sterc/FormIt): он отправляет и обрабатывает формы стандартным методом браузера, с перезагрузкой страницы. FetchIt использует FormIt (или ваш сниппет) и обрабатывает формы «на лету» через Fetch API. - -Близкий по серверной части аналог — [AjaxForm](https://github.com/modx-pro/AjaxForm). У FetchIt другие плюсы на фронте: - -## Никаких зависимостей - -FetchIt не тянет внешних JS-библиотек. У AjaxForm их три: [jQuery](https://github.com/jquery/jquery), [jquery-form](https://github.com/jquery-form/form/) и [jGrowl](https://github.com/stanlemon/jGrowl). - -Уведомления (jGrowl) можно переопределить и в AjaxForm. jQuery и jquery-form заменить сложнее. - -## Современный код - -Скрипт вместе с proof-of-work, адаптерами капч и уведомлениями весит около 30 КБ, в gzip около 9 КБ. Минифицированный `fetchit.min.js` весит около 17 КБ, в gzip около 7 КБ: его можно поставить в настройку `fetchit.frontend.js`. Других файлов скрипт не подключает. Сниппет подключает его с атрибутом `defer`, чтобы не блокировать загрузку страницы. На фронте — нативный Fetch API и `FormData` (включая файлы). - -## Удобство - -Свою вёрстку оставляете как есть: достаточно чанка формы и атрибутов `data-error`. Всплывающие сообщения и модалки подключаете через события и `FetchIt.Message`. В документации есть готовые примеры под Bootstrap, Bulma, UIKit, Notyf, SweetAlert2 и другие. +

+ + + FetchIt + +

+ +**Формы на MODX без перезагрузки страницы, с защитой от спама из коробки.** + +[![MODX 2.8+ | 3.x](https://img.shields.io/badge/MODX-2.8%2B%20%7C%203.x-102C53)](https://modx.com/) +[![PHP 7.4+](https://img.shields.io/badge/PHP-7.4%2B-777BB4)](https://www.php.net/) +[![TypeScript](https://img.shields.io/badge/types-TypeScript-3178C6)](#typescript) +[![CI](https://github.com/GulomovCreative/FetchIt/actions/workflows/ci.yml/badge.svg)](https://github.com/GulomovCreative/FetchIt/actions/workflows/ci.yml) +[![License: GPL-2.0](https://img.shields.io/badge/License-GPL--2.0-blue)](core/components/fetchit/docs/license.txt) + +FetchIt отправляет формы на сайте MODX через Fetch API: без перезагрузки страницы, без jQuery и других библиотек. Обрабатывает их [FormIt](https://github.com/Sterc/FormIt) со всеми его хуками и валидацией или ваш сниппет. Каждая форма сразу защищена от спама: одноразовый токен, время заполнения, поле-ловушка и лимит отправок, а при необходимости proof-of-work и капча. Один пакет для MODX 2.8 и MODX 3. + +## Содержание + +- [Возможности](#возможности) +- [Установка](#установка) +- [Быстрый старт](#быстрый-старт) +- [Параметры сниппета](#параметры-сниппета) +- [Защита от спама](#защита-от-спама) +- [Уведомления](#уведомления) +- [События и JavaScript](#события-и-javascript) +- [TypeScript](#typescript) +- [Системные настройки](#системные-настройки) +- [Переход на FetchIt 4](#переход-на-fetchit-4) +- [Требования](#требования) +- [Разработка](#разработка) +- [Лицензия](#лицензия) ## Возможности -- **FormIt из коробки.** Параметры вроде `&hooks`, `&validate`, `&emailTo` передаются в FormIt без обёрток. -- **Свой сниппет.** В `&snippet` указываете обработчик, который возвращает JSON (`success`, `message`, `data`). -- **Ошибки полей.** Элементы с `data-error="fieldName"` получают текст валидации; к полям можно повесить CSS-классы из системных настроек. -- **События.** `fetchit:before` (можно отменить отправку и дополнить `FormData`), `fetchit:after`, `fetchit:success`, `fetchit:error`, `fetchit:reset`. -- **Уведомления.** Свой `FetchIt.Message` или встроенные уведомления через настройку `fetchit.frontend.default.notifier`, без сторонних библиотек. -- **Типы для TypeScript.** Рядом со скриптом лежит `fetchit.d.ts`: `window.FetchIt`, конфигурация, `FetchIt.Message` и события с их `detail`. -- **Несколько форм на странице.** Каждый вызов сниппета получает свой ключ `data-fetchit`, каждая форма свой экземпляр обработчика. -- **Защита от спама.** Включена по умолчанию: одноразовый подписанный токен, минимальное время заполнения, скрытое поле-ловушка и лимит отправок, и при отправке через FetchIt, и без JavaScript. Свои правила добавляются плагином на событие `OnFetchItBeforeProcess`. -- **Очистка после успеха.** Параметр `&clearFieldsOnSuccess` (по умолчанию включён). -- **Fenom.** Вызов через pdoTools/`{'!FetchIt' | snippet}` поддерживается. +- **FormIt из коробки.** Параметры вроде `&hooks`, `&validate`, `&emailTo` передаются в FormIt как есть, без обёрток. Вместо FormIt можно указать свой сниппет, который возвращает JSON (`success`, `message`, `data`). +- **Своя вёрстка.** Нужны только чанк формы и атрибуты: `data-error` для ошибок полей, `data-success` и `data-validation-error` для сообщений формы. Классы для полей с ошибкой задаются в системных настройках. +- **Работает и без JavaScript.** С FormIt форма отправляется обычным способом, сообщения и введённые значения выводятся его плейсхолдерами. Кроме случая, когда включены proof-of-work или капча: им нужен JavaScript. +- **Защита от спама по умолчанию:** одноразовый подписанный токен, минимальное время заполнения, скрытое поле-ловушка и лимит отправок. По желанию proof-of-work и капча: Cloudflare Turnstile, Google reCAPTCHA v3 или Яндекс SmartCaptcha. Свои правила добавляются плагином. +- **Никаких зависимостей.** Скрипт не тянет jQuery и сторонних библиотек, использует нативные Fetch API и `FormData` (с файлами). Подключается с `defer`, других файлов не грузит, кроме скрипта сервиса капчи, если она включена. +- **Встроенные уведомления** или свои через `FetchIt.Message`: Bootstrap, SweetAlert2, что угодно. +- **События** `fetchit:before`, `fetchit:after`, `fetchit:success`, `fetchit:error`, `fetchit:reset`: дополнить данные, отменить отправку, показать модалку. +- **Типы для TypeScript** лежат рядом со скриптом. +- **Несколько форм на странице**, каждая со своим ключом и обработчиком, если вызовы сниппета различаются параметрами. +- **Fenom и `@FILE`-чанки** через pdoTools на MODX 2 и MODX 3. -## Уведомления +## Установка -![Встроенные уведомления FetchIt: ошибка и успешная отправка](docs/images/notifier-desktop-ru.png) +FetchIt бесплатно ставится через Менеджер пакетов из официального репозитория [modx.com](https://modx.com/extras/package/fetchit) или из маркетплейса [modstore.pro](https://modstore.pro/packages/utilities/fetchit) ([как подключить репозиторий](https://modstore.pro/faq)). Если FormIt не установлен, установщик скачает его из репозитория modx.com; если это не удалось, об этом будет сказано в журнале установки, и FormIt нужно поставить вручную. -С настройкой `fetchit.frontend.default.notifier` ответы сервера и ошибки отправки показываются ещё и уведомлениями в углу страницы. Текст выводится как текст: теги из него убираются, HTML-сущности вроде `&` остаются как есть. +Пакет можно собрать и из этого репозитория, см. [Разработка](#разработка). -- Программы экранного доступа читают уведомления из двух скрытых live-областей, которые есть на странице заранее: ошибку сразу (`role="alert"`), успех в свою очередь (`role="status"`). -- Уведомление закрывается кнопкой или само через 6 секунд. Пока на нём курсор или фокус, отсчёт стоит, а потом начинается заново. Если закрыть уведомление с клавиатуры, фокус перейдёт на соседнее уведомление или вернётся туда, откуда пришёл. -- Одновременно видно не больше трёх. Уведомление, на котором фокус, лишним не считается. +## Быстрый старт -Стили подключаются первыми в ``, с селекторами из одного класса. Они сильнее общих правил сайта для элементов (`button { … }`) и проигрывают любому правилу сайта с классом. Правила внутри `@layer` (например, в Tailwind 4) им проигрывают, для них проще менять переменные. По умолчанию это пастельные цвета Tailwind CSS 4, как в его алертах: фон из оттенка 100, рамка из 200, текст из 800 (контраст текста около 6.5:1, с запасом для WCAG AA). Там, где браузер понимает `oklch()`, они заданы точно, в остальных браузерах в hex: +Вызов сниппета там, где должна быть форма. Вызов обязательно некешируемый (`[[!FetchIt]]`): у каждого вывода формы свой токен, а скрипт подключается, только если сниппет сработал в этом запросе. -```css -.fetchit-toasts { - --fetchit-toast-success-bg: oklch(96.2% 0.044 156.743); /* green-100, #dcfce7 */ - --fetchit-toast-success-border: oklch(92.5% 0.084 155.995); /* green-200, #b9f8cf */ - --fetchit-toast-success-text: oklch(44.8% 0.119 151.328); /* green-800, #016630 */ - --fetchit-toast-error-bg: oklch(93.6% 0.032 17.717); /* red-100, #ffe2e2 */ - --fetchit-toast-error-border: oklch(88.5% 0.062 18.334); /* red-200, #ffc9c9 */ - --fetchit-toast-error-text: oklch(44.4% 0.177 26.899); /* red-800, #9f0712 */ -} +```modx +[[!FetchIt? + &form=`myForm.tpl` + &hooks=`email` + &emailTo=`info@example.com` + &emailSubject=`Заявка с сайта` + &validate=`name:required,email:email:required` + &successMessage=`Сообщение отправлено` +]] ``` -На сайте с Tailwind 4 можно взять его переменные, например `--fetchit-toast-success-bg: var(--color-emerald-100)`. +Чанк `myForm.tpl`, обычная форма с атрибутами FetchIt: + +```html +
+ + + + +
[[+fi.successMessage]]
+
[[+fi.validation_error_message]]
+
+``` -Если на сайте Content-Security-Policy запрещает встроенные стили (`style-src` без `'unsafe-inline'`), дайте скрипту FetchIt `nonce`: стили получат тот же. Иначе FetchIt предупредит в консоли, и уведомления придётся оформить самим. +- `data-error="name"` получает текст ошибки поля `name`, а само поле получает `aria-invalid` и класс из `fetchit.frontend.input.invalid.class`. +- `data-custom="name"` (необязательно) получает класс из `fetchit.frontend.custom.invalid.class`: например, обёртка поля. +- `data-success` и `data-validation-error` показывают сообщение формы. +- Плейсхолдеры `[[+fi.…]]` нужны для отправки без JavaScript. Если это не важно, их можно не ставить. -Свой `FetchIt.Message` важнее настройки. Если в нём нет ни `success`, ни `error` (например, только спиннер в `before` и `after`), встроенные уведомления их добавят. Включить уведомления можно и из своего скрипта, с другой подписью кнопки или временем показа (`0` значит «пока не закроют»): `FetchIt.Message = FetchIt.createNotifier({ closeLabel: 'Закрыть', duration: 4000 })`. +FetchIt сам добавит в форму ключ `data-fetchit`, служебные поля защиты и подключит скрипт. Готовый пример есть в чанке `tpl.FetchIt.example`. -До FetchIt 4 настройка подключала библиотеку Notyf. Теперь её нет: стили для `.notyf__toast` и скрипты, которые вызывают `new Notyf()`, нужно поменять или подключить Notyf самим. +Подробная [документация](https://docs.modx.pro/components/fetchit/) с примерами: разметка, уведомления, модалки, клиентская валидация, JS API. -## TypeScript +## Параметры сниппета -Типы лежат в `assets/components/fetchit/js/fetchit.d.ts`. Скопируйте файл в проект или подключите его: +| Параметр | По умолчанию | Что делает | +|---|---|---| +| `form` | `tpl.FetchIt.example` | Чанк формы. С pdoTools можно `@FILE`, `@INLINE` и Fenom | +| `snippet` | `FormIt` | Сниппет, который обрабатывает форму, см. ниже | +| `actionUrl` | `[[+assetsUrl]]action.php` | Адрес, на который отправляется форма | +| `clearFieldsOnSuccess` | `1` | Очистить поля после успешной отправки | -```ts -/// +Остальные параметры передаются обрабатывающему сниппету: для FormIt это `&hooks`, `&validate`, `&emailTo`, `&successMessage`, `&placeholderPrefix` и все прочие. -// События приходят на document и не всплывают. -document.addEventListener('fetchit:error', event => { - // response равен null, если ответа FetchIt нет: сеть, чужой ответ, капча без ответа - if (event.detail.response === null) { - console.error(event.detail.error) - } -}) +Свой сниппет получает отправленные поля в `$fields` (без служебных полей защиты) и возвращает ответ через FetchIt: -const form = document.querySelector('form') -if (form) { - FetchIt.instances.get(form)?.setError('email', 'Проверьте адрес') +```php +$FetchIt = FetchIt::service($modx); +if (empty($fields)) { + // Вывод формы, а не отправка: сниппет вызывается и при каждом выводе. + return ''; +} +if (empty($fields['email'])) { + return $FetchIt->error('Проверьте форму', ['email' => 'Укажите email']); } +// ... сохранить, отправить письмо +return $FetchIt->success('Спасибо, заявка принята'); ``` -Скрипт проверяется по этому же файлу: и `FetchIt` с экземплярами форм, и `detail` каждого события, так что типы и код не расходятся. Уберите своё объявление `FetchIt`, если оно было (`declare var FetchIt: any`): они будут конфликтовать. Глобальный `FetchIt` появляется, когда отработал отложенный `fetchit.js`, поэтому `FetchIt.Message` задавайте из отложенного скрипта или по `DOMContentLoaded`. - -Минимальный вызов: - -```modx -[[!FetchIt? - &snippet=`FormIt` - &form=`myForm.tpl` - &hooks=`email` - &emailTo=`info@domain.com` - &validate=`name:required,email:required` - &successMessage=`Сообщение отправлено` -]] -``` +Проверка `empty($fields)` обязательна: сниппет вызывается и при каждом выводе формы, с пустым `$fields`, иначе он, например, отправит пустое письмо на каждый просмотр страницы. При отправке без JavaScript ответ сниппета не выводится: сообщение на странице покажется, только если сниппет сам поставит плейсхолдеры. ## Защита от спама Каждая форма FetchIt защищена без настройки. Проверки проходят до FormIt или вашего сниппета, и при отправке через FetchIt, и при обычной отправке формы без JavaScript: -- **Токен.** В форму добавляется скрытое поле `fetchit_token` с подписью ключа формы, времени вывода и случайного числа. Токен одноразовый и не требует сессии. Новый токен выдаётся только в ответ на правильно подписанный токен этой формы, так что бот без загрузки страницы не отправит форму. Каждый ответ через FetchIt приносит следующий токен, и форму можно отправлять повторно без перезагрузки. +- **Токен.** В форму добавляется скрытое поле `fetchit_token` с подписью ключа формы, времени вывода и случайного числа. Токен одноразовый и не требует сессии. Новый токен выдаётся только в ответ на правильно подписанный токен этой формы, так что бот без загрузки страницы не отправит форму. Ответ на отправку с правильно подписанным токеном приносит следующий токен (кроме отказа по лимиту), и форму можно отправлять повторно без перезагрузки. - **Время заполнения.** Форма, отправленная быстрее `fetchit.protection.min_time` секунд после вывода страницы (по умолчанию 3), отклоняется. Повторная отправка считается от того же вывода, поэтому человек, который исправил поле после ошибки, отказ не получит. - **Ловушка.** Скрытое поле со случайным для каждой установки именем люди не видят, а автозаполнение браузера его не узнаёт. Если оно заполнено, бот получает ответ об успешной отправке с `successMessage` формы, но форма не обрабатывается и письмо не уходит. Такие случаи пишутся в журнал. - **Лимит.** Не больше `fetchit.protection.rate_limit` отправок одной формы с одного адреса за `fetchit.protection.rate_window` секунд (по умолчанию 10 за 10 минут). Считаются все попытки с правильно подписанным токеном, в том числе отклонённые, так что повтором старого токена лимит не обойти. @@ -115,7 +134,7 @@ if (form) { ### Proof-of-work и капча -Обе проверки выключены по умолчанию и нужны, если спам проходит через основные проверки. Работают они, только когда включена защита (`fetchit.protection`). +Обе проверки выключены по умолчанию и нужны, если спам проходит через основные. Работают они, только когда включена защита (`fetchit.protection`). **Proof-of-work.** `fetchit.protection.pow` задаёт сложность в битах: 0 выключает, больше 24 не бывает (значение сверх этого или не число пишется в журнал и заменяется). Перед отправкой браузер ищет число `n`, для которого SHA-256 от `токен:n` начинается с этого количества нулевых бит, и отправляет его в поле `fetchit_pow`. Решение начинается, как только посетитель зашёл в форму, так что к нажатию кнопки оно обычно готово, а боту каждая отправка стоит работы процессора. При 16 битах в среднем нужно около 65 тысяч хешей: компьютеру около трети секунды, телефону в несколько раз больше. Каждый следующий бит удваивает время, 20 бит примерно в 16 раз дольше. Если страница из кеша просит меньше, чем сервер, FetchIt сам решит задачу заново и отправит форму ещё раз. Форму без JavaScript при включённом proof-of-work отправить нельзя. @@ -137,16 +156,16 @@ if (form) { - **Кеш всей страницы** (nginx, CDN, плагины статического кеша) отдаёт всем посетителям один и тот же токен. Через FetchIt это работает за счёт повторной отправки, но страницы с формами лучше исключить из такого кеша: без JavaScript первая отправка будет отклонена. - **FormIt 5.2 и новее** умеет отправлять формы через AJAX сам. Для форм FetchIt этот режим выключается: FetchIt убирает сохранённые FormIt параметры, плейсхолдер `fi.ajaxToken` и скрипт `formit.js`, иначе форму можно было бы отправить через `action.php` FormIt в обход защиты. Формы, которые на той же странице выводит сам `[[!FormIt]]`, свой AJAX-режим сохраняют. reCAPTCHA из FormIt 5.2 получает ответ через `formit.js`, поэтому в формах FetchIt она не работает: включайте капчу через `fetchit.captcha`. Хуки капчи, которые читают ответ из своего поля сами, работают, пока в `fetchit.captcha` не выбран тот же сервис. -- **Формы, загруженные через AJAX,** получают токен, только если их выводит некешируемый вызов `[[!FetchIt]]`. -- **Свой JS вместо встроенного скрипта** должен отправлять поле `fetchit_token` из формы и после каждого ответа брать следующий токен из заголовка `X-FetchIt-Token`. Если ответ пришёл с заголовком `X-FetchIt-Refused: token`, отправьте форму ещё раз с новым токеном. С proof-of-work нужно отправлять в `fetchit_pow` решение для того токена, который уходит в этом запросе, и решать заново при повторе. При отказе `pow` заголовок `X-FetchIt-Pow` сообщает нужную сложность. С капчей нужно отправлять ответ сервиса в его поле (`cf-turnstile-response`, `g-recaptcha-response` или `smart-token`), а reCAPTCHA v3 выполнять с действием `fetchit`. +- **Формы, вставленные на страницу через AJAX** после её загрузки, FetchIt сам не подхватывает: скрипт находит формы при загрузке страницы. Такие формы отправятся обычным способом. +- **Свой JS вместо встроенного скрипта** должен отправлять форму на `action.php` с заголовком `X-FetchIt-Action`, равным атрибуту `data-fetchit` формы, и полем `pageId` с id страницы. Он должен отправлять поле `fetchit_token` из формы и после каждого ответа брать следующий токен из заголовка `X-FetchIt-Token`. Если ответ пришёл с заголовком `X-FetchIt-Refused: token`, отправьте форму ещё раз с новым токеном. С proof-of-work нужно отправлять в `fetchit_pow` решение для того токена, который уходит в этом запросе, и решать заново при повторе. При отказе `pow` заголовок `X-FetchIt-Pow` сообщает нужную сложность. С капчей нужно отправлять ответ сервиса в его поле (`cf-turnstile-response`, `g-recaptcha-response` или `smart-token`), а reCAPTCHA v3 выполнять с действием `fetchit`. - **За прокси или CDN** все посетители приходят с одного адреса, и лимит становится общим на сайт. Перечислите адреса прокси в `fetchit.protection.proxies` (IP или CIDR через запятую) и заголовок с адресом посетителя в `fetchit.protection.ip_header` (`X-Forwarded-For`, `CF-Connecting-IP`). -- **Отметки использованных токенов** хранятся в `core/cache/fetchit/tokens/`, «Очистить кеш» их не трогает. Если папка недоступна для записи, формы отклоняются, а причина пишется в журнал. На нескольких серверах без общего `core/cache` токен можно использовать по разу на каждом сервере. +- **Отметки использованных токенов** хранятся в `core/cache/fetchit/tokens/`, «Очистить кеш» их не трогает. Если папка недоступна для записи, формы отклоняются, а причина пишется в журнал (если он не выключен). На нескольких серверах без общего `core/cache` токен можно использовать по разу на каждом сервере. - **Журнал:** `fetchit.protection.log` 0 выключает его, 1 (по умолчанию) пишет проблемы и отказы, которые могут задеть людей (ловушка, лимит, запись отметок, недоступный сервис капчи), 2 пишет все отказы, в том числе ответы капчи, которые сервис не принял. Ошибки в настройках капчи и proof-of-work пишутся всегда. - **На сайте для разработки** и в автотестах поставьте `fetchit.protection.min_time` и `fetchit.protection.rate_limit` в 0. Выключать всю защиту (`fetchit.protection`) стоит только для отладки. ### Свои правила -Плагин на событие `OnFetchItBeforeProcess` получает `$action`, `$fields` (при отправке через FetchIt без файлов), `$properties` (может быть `null`) и `$FetchIt`. Чтобы отклонить отправку, он выводит сообщение для посетителя или ключ лексикона через `$modx->event->output()`. Возврат строки через `return` отправку не отклоняет: MODX только запишет её в журнал. +Плагин на событие `OnFetchItBeforeProcess` получает `$action`, `$fields` (отправленные поля без служебных и без файлов), `$properties` (может быть `null`) и `$FetchIt`. Чтобы отклонить отправку, он выводит сообщение для посетителя или ключ лексикона через `$modx->event->output()`. Возврат строки через `return` отправку не отклоняет: MODX только запишет её в журнал. ```php // Плагин на событие OnFetchItBeforeProcess @@ -158,21 +177,121 @@ if (preg_match('/@(mailinator|tempmail)\./i', $email)) { Событие срабатывает и при выключенной защите. -## Ветки и версии +## Уведомления -| Ветка | Пакет | MODX | -|-------|-------|------| -| `master` | 1.x | 2.x | -| `4.x` | 4.x (в разработке) | 2.x и 3.x | -| `next` | 3.x | 3.x | +![Встроенные уведомления FetchIt: ошибка и успешная отправка](docs/images/notifier-desktop-ru.png) -FetchIt 4 будет одним пакетом для MODX 2 и MODX 3 и заменит линии 1.x и 3.x. Он разрабатывается в ветке `4.x`; с выходом 4.0 она станет `master`, а `next` будет заморожена. +С настройкой `fetchit.frontend.default.notifier` ответы сервера и ошибки отправки показываются ещё и уведомлениями в углу страницы. Текст выводится как текст: теги из него убираются, HTML-сущности вроде `&` остаются как есть. -На [extras.modx.com](https://extras.modx.com/package/fetchit): **3.1.2-pl** (MODX 3) и **1.1.2-pl** (MODX 2). +- Программы экранного доступа читают уведомления из двух скрытых live-областей, которые есть на странице заранее: ошибку сразу (`role="alert"`), успех в свою очередь (`role="status"`). +- Уведомление закрывается кнопкой или само через 6 секунд. Пока на нём курсор или фокус, отсчёт стоит, а потом начинается заново. Если закрыть уведомление с клавиатуры, фокус перейдёт на соседнее уведомление или вернётся туда, откуда пришёл. +- Одновременно видно не больше трёх. Уведомление, на котором фокус, лишним не считается. + +Стили подключаются первыми в ``, с селекторами из одного класса. Они сильнее общих правил сайта для элементов (`button { … }`) и проигрывают любому правилу сайта с классом. Правила внутри `@layer` (например, в Tailwind 4) им проигрывают, для них проще менять переменные. По умолчанию это пастельные цвета Tailwind CSS 4, как в его алертах: фон из оттенка 100, рамка из 200, текст из 800 (контраст текста около 6.5:1, с запасом для WCAG AA). Там, где браузер понимает `oklch()`, они заданы точно, в остальных браузерах в hex: + +```css +.fetchit-toasts { + --fetchit-toast-success-bg: oklch(96.2% 0.044 156.743); /* green-100, #dcfce7 */ + --fetchit-toast-success-border: oklch(92.5% 0.084 155.995); /* green-200, #b9f8cf */ + --fetchit-toast-success-text: oklch(44.8% 0.119 151.328); /* green-800, #016630 */ + --fetchit-toast-error-bg: oklch(93.6% 0.032 17.717); /* red-100, #ffe2e2 */ + --fetchit-toast-error-border: oklch(88.5% 0.062 18.334); /* red-200, #ffc9c9 */ + --fetchit-toast-error-text: oklch(44.4% 0.177 26.899); /* red-800, #9f0712 */ +} +``` + +На сайте с Tailwind 4 можно взять его переменные, например `--fetchit-toast-success-bg: var(--color-emerald-100)`. + +Если на сайте Content-Security-Policy запрещает встроенные стили (`style-src` без `'unsafe-inline'`), дайте скрипту FetchIt `nonce`: стили получат тот же. Иначе FetchIt предупредит в консоли, и уведомления придётся оформить самим. + +Свой `FetchIt.Message` важнее настройки. Если в нём нет ни `success`, ни `error` (например, только спиннер в `before` и `after`), встроенные уведомления их добавят, если `FetchIt.Message` задан до `DOMContentLoaded`: прямо в отложенном скрипте, который подключён после `fetchit.js`. Включить уведомления можно и из своего скрипта, с другой подписью кнопки или временем показа (`0` значит «пока не закроют»): `FetchIt.Message = FetchIt.createNotifier({ closeLabel: 'Закрыть', duration: 4000 })`. + +До FetchIt 4 настройка подключала библиотеку Notyf. Теперь её нет: стили для `.notyf__toast` и скрипты, которые вызывают `new Notyf()`, нужно поменять или подключить Notyf самим. + +## События и JavaScript + +События приходят на `document` и не всплывают. В `event.detail` лежат форма (`form`), её данные (`formData`) и экземпляр FetchIt (`fetchit`), а начиная с `fetchit:after` ещё и ответ сервера (`response`). У `fetchit:error` без ответа есть причина (`error`), у `fetchit:reset` только `form` и `fetchit`. + +| Событие | Когда | Отмена (`event.preventDefault()`) | +|---|---|---| +| `fetchit:before` | перед отправкой, в `formData` можно дописать поля | форма не отправляется | +| `fetchit:after` | пришёл ответ FetchIt | ответ не обрабатывается: ни ошибок полей, ни сообщения, ни уведомления, ни событий `fetchit:success` и `fetchit:error`, ни очистки | +| `fetchit:success` | форма принята | поля не очищаются | +| `fetchit:error` | форма отклонена или не отправилась; `response` равен `null`, если ответа FetchIt нет, причина в `error` | ошибки полей и сообщение формы не выводятся | +| `fetchit:reset` | форму сбрасывают: кнопкой, `form.reset()` или после успешной отправки; значения полей в этот момент ещё прежние | нельзя | + +```js +document.addEventListener('fetchit:before', ({ detail }) => { + detail.formData.append('page', location.pathname) +}) + +document.addEventListener('fetchit:success', ({ detail }) => { + ym(12345678, 'reachGoal', 'form_' + detail.form.id) +}) +``` + +`FetchIt.Message` принимает хуки `before`, `after(message)`, `success(message)`, `error(message)` и `reset`. Они вызываются перед событием того же момента (кроме `reset`: он после `fetchit:reset`), так что отмена события их не отменяет: + +```js +FetchIt.Message = { + success (message) { Swal.fire({ icon: 'success', text: message }) }, + error (message) { Swal.fire({ icon: 'error', text: message }) }, +} +``` + +Глобальный `FetchIt` появляется, когда отработал отложенный `fetchit.js`, поэтому `FetchIt.Message` задавайте из отложенного скрипта, подключённого после него, или по `DOMContentLoaded`. Экземпляр формы: `FetchIt.instances.get(form)`, у него есть `setError(name, message)`, `clearErrors()`, `setFormMessage(type, message)` и другие методы. + +## TypeScript + +Типы лежат в `assets/components/fetchit/js/fetchit.d.ts`. Скопируйте файл в проект или подключите его: + +```ts +/// + +document.addEventListener('fetchit:error', event => { + // response равен null, если ответа FetchIt нет: сеть, чужой ответ, капча без ответа + if (event.detail.response === null) { + console.error(event.detail.error) + } +}) + +const form = document.querySelector('form') +if (form) { + FetchIt.instances.get(form)?.setError('email', 'Проверьте адрес') +} +``` + +Скрипт проверяется по этому же файлу: и `FetchIt` с экземплярами форм, и `detail` каждого события, так что типы и код не расходятся. Уберите своё объявление `FetchIt`, если оно было (`declare var FetchIt: any`): они будут конфликтовать. + +## Системные настройки + +Все настройки в пространстве имён `fetchit`. + +| Настройка | По умолчанию | Что делает | +|---|---|---| +| `fetchit.frontend.js` | `[[+assetsUrl]]js/fetchit.js` | Скрипт FetchIt. Минифицированный `js/fetchit.min.js` весит около 19 КБ вместо 36 (в gzip около 7 КБ вместо 10) | +| `fetchit.frontend.js.classname` | `FetchIt` | Класс, который обрабатывает формы, если вы расширили встроенный | +| `fetchit.frontend.input.invalid.class` | `is-invalid` | Класс поля с ошибкой | +| `fetchit.frontend.custom.invalid.class` | | Класс для элементов `data-custom` поля с ошибкой | +| `fetchit.frontend.default.notifier` | `Нет` | Показывать ответы встроенными [уведомлениями](#уведомления) | +| `fetchit.protection` | `Да` | [Защита от спама](#защита-от-спама). Выключайте только для отладки | +| `fetchit.protection.secret` | случайный | Ключ подписи токенов, генерируется при установке | +| `fetchit.protection.min_time` | `3` | Минимальное время заполнения, секунд; 0 выключает | +| `fetchit.protection.token_ttl` | `86400` | Сколько секунд страница с формой остаётся действительной; 0 и всё, что больше 30 дней, значит 30 дней | +| `fetchit.protection.rate_limit` | `10` | Отправок одной формы с одного адреса за окно; 0 выключает | +| `fetchit.protection.rate_window` | `600` | Окно лимита, секунд; 0 выключает лимит | +| `fetchit.protection.log` | `1` | Журнал защиты: 0 ничего, 1 проблемы, 2 все отказы | +| `fetchit.protection.proxies` | | Доверенные прокси или CDN, IP или CIDR через запятую | +| `fetchit.protection.ip_header` | `X-Forwarded-For` | Заголовок с адресом посетителя от доверенного прокси | +| `fetchit.protection.pow` | `0` | Сложность proof-of-work, бит; 0 выключает, не больше 24 | +| `fetchit.captcha` | | `turnstile`, `recaptcha` или `smartcaptcha`; пусто без капчи | +| `fetchit.captcha.site_key` | | Ключ сайта у сервиса капчи | +| `fetchit.captcha.secret_key` | | Секретный ключ у сервиса капчи | +| `fetchit.captcha.min_score` | `0.5` | reCAPTCHA v3: минимальная оценка, от 0 до 1 | ## Переход на FetchIt 4 -FetchIt 4 это один пакет для MODX 2.8 и MODX 3 вместо линий 1.x и 3.x. Он ставится поверх установленной версии через Менеджер пакетов: системные настройки и чанки остаются как есть. Сниппет и плагин FetchIt при обновлении заменяются, так что правки в их коде пропадут; вызовы сниппета на страницах менять не нужно. Обновление проверяется в CI с 1.1.3 на MODX 2 и с 3.1.4 на MODX 3. +FetchIt 4 это один пакет для MODX 2.8 и MODX 3 вместо линий 1.x и 3.x. Он ставится поверх установленной версии через Менеджер пакетов: системные настройки и чанки остаются как есть. Сниппет и плагин FetchIt при обновлении заменяются вместе с параметрами сниппета по умолчанию, так что правки в их коде и параметрах пропадут; вызовы сниппета на страницах менять не нужно. Обновление проверяется в CI с 1.1.3 на MODX 2 и с 3.1.4 на MODX 3. Прежний API работает на обеих версиях MODX: @@ -183,6 +302,8 @@ FetchIt 4 это один пакет для MODX 2.8 и MODX 3 вместо ли Что может задеть ваш код: +- в форму добавляются служебные поля защиты; свой JS вместо встроенного должен отправлять токен (см. [Что стоит учесть](#что-стоит-учесть)); +- настройка уведомлений больше не подключает Notyf (см. [Уведомления](#уведомления)); - `fetchit:error` срабатывает и при сбое запроса; тогда `detail.response` равен `null`, а ошибка лежит в `detail.error`; - обрабатывающий сниппет получает в `fields` только отправленную форму: `$_POST` и файлы из `$_FILES` при отправке через FetchIt, только `$_POST` при обычной отправке формы; без GET-параметров и cookies; - `fetchit:success` можно отменить: `event.preventDefault()` оставит поля заполненными; @@ -191,37 +312,34 @@ FetchIt 4 это один пакет для MODX 2.8 и MODX 3 вместо ли Полный список изменений в [changelog](core/components/fetchit/docs/changelog.txt). -## Документация - -Подробная [документация](https://docs.modx.pro/components/fetchit/) с примерами: разметка, уведомления, модалки, клиентская валидация, JS API. - -# Установка +## Требования -Компонент бесплатно ставится через Менеджер пакетов из: +- MODX 2.8 или MODX 3 (проверяется на 2.8.6 и 3.2.4). +- PHP 7.4 или новее. Синтаксис и PHPUnit проверяются на PHP 7.4–8.4, установка и отправка форм на MODX на PHP 7.4 и 8.3. +- FormIt, если формы обрабатывает он; ставится вместе с FetchIt. +- pdoTools, если чанки форм на Fenom или в файлах. +- Браузеры из `browserslist` в `package.json` (актуальные версии всех основных браузеров): скрипт собирается в синтаксис ES2019, и CI это проверяет. Без JavaScript формы с FormIt работают как обычные, если не включены proof-of-work и капча. -- маркетплейса [modstore.pro](https://modstore.pro/packages/utilities/fetchit) - - [инструкция](https://modstore.pro/faq) по подключению репозитория -- официального репозитория [modx.com](https://modx.com/extras/package/fetchit) +## Разработка -Либо соберите transport-пакет из `_build/` в этом репозитории. +### Окружение -# Разработка - -Нужны Node.js 22.22+ или 24.15+ (версия для CI в `.node-version`), PHP 7.4+ с Composer и Docker. +Нужны Node.js 22.22+, 24.15+ или 26+ (версия для CI в `.node-version`), PHP 7.4+ с Composer и Docker. ```sh npm ci && composer install -npm run build # src/ → assets/components/fetchit/js/: скрипт и fetchit.d.ts -npm run lint # oxlint -npm run typecheck # tsc: код и собранные публичные типы глазами сайта (tests/types, после npm run build) -npm test # Vitest -vendor/bin/phpunit # PHPUnit +npm run build # src/ → assets/components/fetchit/js/: скрипт в ES2019 и fetchit.d.ts +npm run check:syntax # собранный скрипт разбирается как ES2019 +npm run lint # oxlint +npm run typecheck # tsc: код и собранные публичные типы глазами сайта (tests/types, после npm run build) +npm test # Vitest +vendor/bin/phpunit # PHPUnit +npm run screenshots # снимки уведомлений в docs/images/, после правок их стилей +npm run logo # логотип из .github/logo/logo.html (нужна сеть для Google Fonts) ``` -`npm run screenshots` снимает уведомления на демо-форме со свежесобранным скриптом и кладёт картинки в `docs/images/`: для README, документации и статей. Это вид целиком (компьютер, телефон, тёмная тема) и отдельные уведомления, на русском и английском. После правок стилей уведомлений снимки стоит обновить. - -## Локальные сайты +### Локальные сайты Два сайта, MODX 2.8.6 на PHP 7.4 и MODX 3.2.4 на PHP 8.3. Папки компонента из репозитория подключены в оба, поэтому правки PHP и собранного JS видны сразу. Правки в `src/` видны после `npm run build`. @@ -240,7 +358,7 @@ docker compose exec -u www-data modx2 php /extra/_build/ci/install.php /extra/_p docker compose exec -u www-data modx3 php /extra/_build/ci/install.php /extra/_packages/fetchit-<версия>-<релиз>.transport.zip ``` -Без `PKG_DIST=1` `build.php` сразу ставит пакет на тот сайт, где его собирают. +Без `PKG_DIST=1` `build.php` сразу ставит пакет на тот сайт, где его собирают. Для проверки обновлений со старых версий есть чистые сайты без папок репозитория, см. `compose.clean.yml`. Проверки через HTTP и браузерные тесты на локальном сайте: @@ -251,14 +369,30 @@ npx playwright install chromium BASE_URL=http://localhost:8052 FIXTURES="$fixtures" npm run e2e ``` -## CI и релизы +### Как устроено + +- **Сниппет** (`core/components/fetchit/elements/snippets/`) выводит чанк формы, ставит ей ключ `data-fetchit` (хеш параметров вызова) и служебные поля защиты, запоминает свои параметры по этому ключу и при обычной отправке формы без JavaScript обрабатывает её сам. +- **Плагин** на `OnWebPagePrerender` подключает скрипт в ``, если в этом запросе сработал некешируемый вызов сниппета. +- **Скрипт** (`src/`, собирается в `assets/components/fetchit/js/`) отправляет форму на `action.php` с заголовком `X-FetchIt-Action`, решает proof-of-work, получает ответ капчи, выводит ошибки и сообщения, отправляет события. +- **`action.php`** находит параметры формы по ключу, проверяет защиту и вызывает FormIt или ваш сниппет. +- **`FetchItGuard`** проверяет токен, лимит, ловушку, proof-of-work, время заполнения и капчу (`FetchItCaptcha`), затем вызывает `OnFetchItBeforeProcess`. + +### Проверки и релизы -CI проверяет каждый PR: синтаксис PHP 7.4–8.4, PHPUnit, линтер, типы, тесты и актуальность собранного JS, workflow и shell-скрипты, согласованность версий. Затем собирает пакет на MODX 2.8.6, ставит его на MODX 2.8.6 и 3.2.4 и отправляет формы через HTTP (с анонимными сессиями и без) и из браузера (Playwright), в том числе со встроенным уведомителем. +CI проверяет каждый PR: синтаксис PHP 7.4–8.4, PHPUnit, линтер, типы, тесты, актуальность собранного JS и то, что он в синтаксисе ES2019, workflow и shell-скрипты, согласованность версий. Затем собирает пакет на MODX 2.8.6, ставит его на MODX 2.8.6 и 3.2.4, в том числе поверх 1.1.3 и 3.1.4, и отправляет формы через HTTP (с анонимными сессиями и без) и из браузера (Playwright): с защитой, proof-of-work, Turnstile и встроенными уведомлениями. Релиз: -1. Поднять версию в `_build/config.inc.php` и `core/components/fetchit/model/fetchit.class.php`, в `package.json` и `package-lock.json` через `npm version X.Y.Z --no-git-tag-version`. +1. Поднять версию в `_build/config.inc.php` (и при необходимости `release`, например `alpha` → `pl`) и `core/components/fetchit/model/fetchit.class.php`, в `package.json` и `package-lock.json` через `npm version X.Y.Z --no-git-tag-version`. 2. Добавить в `core/components/fetchit/docs/changelog.txt` раздел `## [X.Y.Z] - ГГГГ-ММ-ДД`. 3. Слить это в `master` и запушить тег: `git tag vX.Y.Z && git push origin vX.Y.Z`. Workflow проверит версию, соберёт пакет, прогонит его на MODX 2 и 3 и только потом создаст GitHub-релиз с пакетом. Заметки к релизу собираются из коммитов `feat`, `fix`, `perf` и `refactor` ([conventional commits](https://www.conventionalcommits.org/ru/)); если таких нет, берётся раздел из changelog. + +### Ветки + +FetchIt 4 разрабатывается в ветке `4.x`; с выходом 4.0 она станет `master`. Прежние линии больше не развиваются: 1.x для MODX 2 осталась в истории `master`, 3.x для MODX 3 в ветке `next`. + +## Лицензия + +[GPL-2.0](core/components/fetchit/docs/license.txt). diff --git a/_build/logo.mjs b/_build/logo.mjs new file mode 100644 index 0000000..2366bda --- /dev/null +++ b/_build/logo.mjs @@ -0,0 +1,80 @@ +// Render the logo from .github/logo/logo.html: +// +// npm run logo +// +// logo.png, logo@2x.png 320x240, transparent, for light pages +// logo-dark.png, logo-dark@2x.png the same for dark pages (GitHub's dark theme) +// social.png 1280x640, the preview of links to the +// repository (upload it in Settings > +// General > Social preview) +// +// The fonts come from Google Fonts. Without them the script stops before it +// writes anything, rather than leaving logos in fallback fonts. + +import { writeFileSync } from 'node:fs' +import { pathToFileURL } from 'node:url' +import { chromium } from '@playwright/test' + +const dir = '.github/logo' +const url = pathToFileURL(`${dir}/logo.html`).href +const browser = await chromium.launch() + +// document.fonts.ready resolves even when the fonts failed to load. +async function requireFonts (page, families) { + const missing = await page.evaluate(names => names.filter(name => + ![...document.fonts].some(face => face.family.replace(/"/g, '') === name && face.status === 'loaded')), families) + if (missing.length) { + throw new Error(`The fonts did not load (${missing.join(', ')}); the logo needs the network for Google Fonts`) + } +} + +async function logo (scale, query = '') { + const page = await browser.newPage({ viewport: { width: 320, height: 240 }, deviceScaleFactor: scale }) + await page.goto(url + query) + await page.waitForSelector('body[data-ready]') + await requireFonts(page, ['Outfit', 'JetBrains Mono']) + const png = await page.screenshot({ omitBackground: true }) + await page.close() + return png +} + +// The logo on white, centred on a 1280x640 card: GitHub's size for the preview. +async function social () { + const page = await browser.newPage({ viewport: { width: 1280, height: 640 } }) + await page.goto(url) + await page.waitForSelector('body[data-ready]') + // Outfit has no Cyrillic: the line under the logo is set in Onest. + await page.addStyleTag({ url: 'https://fonts.googleapis.com/css2?family=Onest:wght@400&display=block' }) + await page.evaluate(async () => { + document.documentElement.style.cssText = 'width: 1280px; height: 640px' + document.body.style.cssText = 'width: 1280px; height: 640px; display: grid; place-items: center; background: #fff' + const mark = document.querySelector('.logo') + mark.style.cssText = 'height: auto; padding: 0; transform: scale(2.6)' + const line = document.createElement('div') + line.textContent = 'Формы MODX без перезагрузки страницы, с защитой от спама' + line.style.cssText = 'position: absolute; bottom: 120px; width: 100%; text-align: center; font: 400 30px Onest, sans-serif; color: #555' + document.body.append(line) + await document.fonts.load('400 30px Onest', line.textContent) + }) + await requireFonts(page, ['Outfit', 'JetBrains Mono', 'Onest']) + const png = await page.screenshot() + await page.close() + return png +} + +try { + // Every picture first, then the files: a failure leaves the old ones. + const files = { + 'logo.png': await logo(1), + 'logo@2x.png': await logo(2), + 'logo-dark.png': await logo(1, '?dark'), + 'logo-dark@2x.png': await logo(2, '?dark'), + 'social.png': await social(), + } + for (const [name, png] of Object.entries(files)) { + writeFileSync(`${dir}/${name}`, png) + } + console.log(`The logo is in ${dir}/`) +} finally { + await browser.close() +} diff --git a/assets/components/fetchit/js/fetchit.d.ts b/assets/components/fetchit/js/fetchit.d.ts index 61bbf9d..609e233 100644 --- a/assets/components/fetchit/js/fetchit.d.ts +++ b/assets/components/fetchit/js/fetchit.d.ts @@ -72,9 +72,9 @@ interface FetchItAnswer { /** * Hooks for notifications: set FetchIt.Message to show the answers your - * way. They run before the event of the same moment is dispatched, so - * cancelling the event does not undo them. An exception in a hook is logged - * and does not stop the form. + * way. They run before the event of the same moment is dispatched (reset + * after fetchit:reset), so cancelling the event does not undo them. An + * exception in a hook is logged and does not stop the form. */ interface FetchItMessage { /** On submit, before fetchit:before */ @@ -85,7 +85,7 @@ interface FetchItMessage { success?: ((message: string) => void) | undefined; /** When the form was refused or could not be sent, before fetchit:error */ error?: ((message: string) => void) | undefined; - /** On every reset of the form, the one after a success included */ + /** On every reset of the form, the one after a success included; after fetchit:reset */ reset?: (() => void) | undefined; } @@ -147,7 +147,10 @@ interface FetchItErrorDetail extends FetchItBeforeDetail { error?: unknown; } -/** fetchit:reset, not cancelable */ +/** + * fetchit:reset, not cancelable: the form is being reset (its button, + * form.reset(), or after a success); the fields still hold their values. + */ interface FetchItResetDetail { form: HTMLFormElement; fetchit: FetchItInstance; diff --git a/assets/components/fetchit/js/fetchit.js b/assets/components/fetchit/js/fetchit.js index 8315f2b..5e9535d 100644 --- a/assets/components/fetchit/js/fetchit.js +++ b/assets/components/fetchit/js/fetchit.js @@ -1,7 +1,48 @@ (function() { + //#region \0@oxc-project+runtime@0.151.0/helpers/esm/typeof.js + function _typeof(o) { + "@babel/helpers - typeof"; + return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) { + return typeof o; + } : function(o) { + return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; + }, _typeof(o); + } + //#endregion + //#region \0@oxc-project+runtime@0.151.0/helpers/esm/toPrimitive.js + function toPrimitive(t, r) { + if ("object" != _typeof(t) || !t) return t; + var e = t[Symbol.toPrimitive]; + if (void 0 !== e) { + var i = e.call(t, r || "default"); + if ("object" != _typeof(i)) return i; + throw new TypeError("@@toPrimitive must return a primitive value."); + } + return ("string" === r ? String : Number)(t); + } + //#endregion + //#region \0@oxc-project+runtime@0.151.0/helpers/esm/toPropertyKey.js + function toPropertyKey(t) { + var i = toPrimitive(t, "string"); + return "symbol" == _typeof(i) ? i : i + ""; + } + //#endregion + //#region \0@oxc-project+runtime@0.151.0/helpers/esm/defineProperty.js + function _defineProperty(e, r, t) { + return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, { + value: t, + enumerable: !0, + configurable: !0, + writable: !0 + }) : e[r] = t, e; + } + //#endregion //#region src/captcha.ts var CaptchaError = class extends Error { - name = "CaptchaError"; + constructor(..._args) { + super(..._args); + _defineProperty(this, "name", "CaptchaError"); + } }; const RECAPTCHA_ACTION = "fetchit"; const SCRIPT_WAIT = 1e4; @@ -56,7 +97,8 @@ let widget; let failure; const render = (api) => { - widget ??= api.render(container(form), { + var _widget; + (_widget = widget) !== null && _widget !== void 0 || (widget = api.render(container(form), { sitekey: siteKey, callback: () => { failure = void 0; @@ -65,7 +107,7 @@ failure = String(code); console.error(`FetchIt: Turnstile error ${code}`); } - }); + })); return widget; }; waitFor(() => window.turnstile).then((api) => api && render(api)).catch((error) => console.error(error)); @@ -85,7 +127,10 @@ reset() { if (widget !== void 0) { const id = widget; - safely(() => window.turnstile?.reset(id)); + safely(() => { + var _window$turnstile; + return (_window$turnstile = window.turnstile) === null || _window$turnstile === void 0 ? void 0 : _window$turnstile.reset(id); + }); } } }; @@ -97,8 +142,11 @@ function recaptcha(siteKey) { return { async answer(formData) { - const api = await waitFor(() => window.grecaptcha?.execute ? window.grecaptcha : void 0); - const execute = api?.execute; + const api = await waitFor(() => { + var _window$grecaptcha; + return ((_window$grecaptcha = window.grecaptcha) === null || _window$grecaptcha === void 0 ? void 0 : _window$grecaptcha.execute) ? window.grecaptcha : void 0; + }); + const execute = api === null || api === void 0 ? void 0 : api.execute; if (!api || !execute) throw notLoaded("reCAPTCHA", "www.google.com"); await withTimeout(new Promise((resolve) => api.ready ? api.ready(resolve) : resolve()), SCRIPT_WAIT, "reCAPTCHA"); const token = await withTimeout(execute(siteKey, { action: RECAPTCHA_ACTION }), ANSWER_WAIT, "reCAPTCHA").catch((error) => { @@ -119,22 +167,23 @@ const settle = (token, error) => { const waiting = pending; pending = void 0; - if (error) waiting?.reject(error); - else if (token) waiting?.resolve(token); + if (error) waiting === null || waiting === void 0 || waiting.reject(error); + else if (token) waiting === null || waiting === void 0 || waiting.resolve(token); }; const render = (api) => { if (widget === void 0) { + var _api$subscribe, _api$subscribe2, _api$subscribe3; const id = api.render(container(form), { sitekey: siteKey, invisible: true, callback: (token) => settle(token) }); widget = id; - api.subscribe?.(id, "challenge-hidden", () => setTimeout(() => { + (_api$subscribe = api.subscribe) === null || _api$subscribe === void 0 || _api$subscribe.call(api, id, "challenge-hidden", () => setTimeout(() => { if (!api.getResponse(id)) settle(void 0, new CaptchaError("FetchIt: the check of SmartCaptcha was closed")); }, 1e3)); - api.subscribe?.(id, "network-error", () => settle(void 0, new CaptchaError("FetchIt: SmartCaptcha could not reach its server"))); - api.subscribe?.(id, "javascript-error", (error) => settle(void 0, new CaptchaError(`FetchIt: SmartCaptcha failed: ${JSON.stringify(error)}`))); + (_api$subscribe2 = api.subscribe) === null || _api$subscribe2 === void 0 || _api$subscribe2.call(api, id, "network-error", () => settle(void 0, new CaptchaError("FetchIt: SmartCaptcha could not reach its server"))); + (_api$subscribe3 = api.subscribe) === null || _api$subscribe3 === void 0 || _api$subscribe3.call(api, id, "javascript-error", (error) => settle(void 0, new CaptchaError(`FetchIt: SmartCaptcha failed: ${JSON.stringify(error)}`))); } return widget; }; @@ -157,13 +206,16 @@ pending = void 0; if (widget !== void 0) { const id = widget; - safely(() => window.smartCaptcha?.reset(id)); + safely(() => { + var _window$smartCaptcha; + return (_window$smartCaptcha = window.smartCaptcha) === null || _window$smartCaptcha === void 0 ? void 0 : _window$smartCaptcha.reset(id); + }); } } }; } function createCaptcha(form, config) { - switch (config?.provider) { + switch (config === null || config === void 0 ? void 0 : config.provider) { case "turnstile": return turnstile(form, config.siteKey); case "recaptcha": return recaptcha(config.siteKey); case "smartcaptcha": return smartcaptcha(form, config.siteKey); @@ -181,11 +233,12 @@ } //#endregion //#region src/notifier.ts + var _document$currentScri; const STYLE_ID = "fetchit-toasts-style"; const MAX_TOASTS = 3; const DEFAULT_DURATION = 6e3; const MAX_DELAY = 2 ** 31 - 1; - const nonce = document.currentScript?.nonce || void 0; + const nonce = ((_document$currentScri = document.currentScript) === null || _document$currentScri === void 0 ? void 0 : _document$currentScri.nonce) || void 0; const CSS = ` .fetchit-toasts { position: fixed; z-index: 2147483000; inset-block-end: 1rem; inset-inline-end: 1rem; @@ -228,12 +281,13 @@ let warned = false; let origin = null; function addStyles() { + var _document$head; if (document.getElementById(STYLE_ID)) return; const style = document.createElement("style"); style.id = STYLE_ID; if (nonce) style.nonce = nonce; style.textContent = CSS; - (document.head ?? document.documentElement).prepend(style); + ((_document$head = document.head) !== null && _document$head !== void 0 ? _document$head : document.documentElement).prepend(style); if (!style.sheet && !warned) { warned = true; console.warn("FetchIt: the styles of the notifier were blocked, probably by a Content-Security-Policy (style-src). Give the FetchIt script a nonce, or style .fetchit-toast yourself."); @@ -257,7 +311,8 @@ }); } function regions() { - const parent = document.body ?? document.documentElement; + var _document$body; + const parent = (_document$body = document.body) !== null && _document$body !== void 0 ? _document$body : document.documentElement; let toasts = document.querySelector(".fetchit-toasts"); if (!toasts) { toasts = document.createElement("div"); @@ -330,11 +385,15 @@ const remove = () => { clearTimeout(timer); if (toast.contains(document.activeElement)) { + var _others$find; const others = Array.from(toasts.querySelectorAll(".fetchit-toast__close")).filter((button) => !toast.contains(button)); - const next = others.find((button) => toast.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING) ?? others.at(-1); + const next = (_others$find = others.find((button) => toast.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING)) !== null && _others$find !== void 0 ? _others$find : others.at(-1); if (next) next.focus(); else if (focusable(origin)) origin.focus(); - else document.activeElement?.blur(); + else { + var _document$activeEleme; + (_document$activeEleme = document.activeElement) === null || _document$activeEleme === void 0 || _document$activeEleme.blur(); + } } toast.remove(); }; @@ -535,7 +594,7 @@ if (zeroBits(sha256(`${token}:${n}`)) >= bits) return String(n); if (n % batch === batch - 1) { await new Promise((resolve) => setTimeout(resolve, 0)); - if (signal?.aborted) throw new Error("FetchIt: the proof of work was stopped"); + if (signal === null || signal === void 0 ? void 0 : signal.aborted) throw new Error("FetchIt: the proof of work was stopped"); } } } @@ -564,21 +623,9 @@ data: data !== null && typeof data === "object" ? data : {} }; } - window.FetchIt = class FetchIt { - static forms = []; - static instances = /* @__PURE__ */ new Map(); - static defaultRequestErrorMessage = "Could not send the form. Please try again."; - static tokenField = "fetchit_token"; - static powField = "fetchit_pow"; - static events = { - before: "fetchit:before", - success: "fetchit:success", - error: "fetchit:error", - after: "fetchit:after", - reset: "fetchit:reset" - }; - formData = void 0; + var FetchIt = class FetchIt { constructor(form, config) { + _defineProperty(this, "formData", void 0); if (!(form instanceof HTMLFormElement)) throw new Error("FetchIt: the element is not a form"); this.form = form; this.config = config; @@ -615,17 +662,20 @@ let shown = false; let response; try { + var _this$config$captcha; try { + var _query$headers, _query$headers2, _query$headers$get, _query$headers3, _this$config$pow; await this.protectSubmission(formData); let query = await fetch(this.request, { method: "post", body: formData }); - let next = query.headers?.get("X-FetchIt-Token"); + let next = (_query$headers = query.headers) === null || _query$headers === void 0 ? void 0 : _query$headers.get("X-FetchIt-Token"); this.updateToken(next); - const refused = query.headers?.get("X-FetchIt-Refused"); - const bits = Number(query.headers?.get("X-FetchIt-Pow") ?? 0); - if (next && (refused === "token" || refused === "pow" && bits > (this.config.pow ?? 0))) { + const refused = (_query$headers2 = query.headers) === null || _query$headers2 === void 0 ? void 0 : _query$headers2.get("X-FetchIt-Refused"); + const bits = Number((_query$headers$get = (_query$headers3 = query.headers) === null || _query$headers3 === void 0 ? void 0 : _query$headers3.get("X-FetchIt-Pow")) !== null && _query$headers$get !== void 0 ? _query$headers$get : 0); + if (next && (refused === "token" || refused === "pow" && bits > ((_this$config$pow = this.config.pow) !== null && _this$config$pow !== void 0 ? _this$config$pow : 0))) { + var _query$headers4; if (refused === "pow") this.config.pow = bits; formData.set(FetchIt.tokenField, next); if (this.config.pow) formData.set(FetchIt.powField, await this.solution(next)); @@ -633,7 +683,7 @@ method: "post", body: formData }); - next = query.headers?.get("X-FetchIt-Token"); + next = (_query$headers4 = query.headers) === null || _query$headers4 === void 0 ? void 0 : _query$headers4.get("X-FetchIt-Token"); this.updateToken(next); } if (refused === "captcha" && !this.config.captcha) console.warn("FetchIt: the server asks for a captcha this page does not have; the page may come from a cache made before the captcha was turned on"); @@ -677,8 +727,9 @@ response, fetchit: this })) return; - if (this.config.captcha?.provider !== "recaptcha") try { - window.grecaptcha?.reset?.(); + if (((_this$config$captcha = this.config.captcha) === null || _this$config$captcha === void 0 ? void 0 : _this$config$captcha.provider) !== "recaptcha") try { + var _window$grecaptcha, _window$grecaptcha$re; + (_window$grecaptcha = window.grecaptcha) === null || _window$grecaptcha === void 0 || (_window$grecaptcha$re = _window$grecaptcha.reset) === null || _window$grecaptcha$re === void 0 || _window$grecaptcha$re.call(_window$grecaptcha); } catch (error) { console.error(error); } @@ -688,12 +739,13 @@ this.preserveFormMessagesOnReset = false; } } catch (error) { - if (shown || response?.success) console.error(error); + if (shown || (response === null || response === void 0 ? void 0 : response.success)) console.error(error); else this.failRequest(error, formData); } finally { + var _this$captcha; this.enableFields(); this.pending = false; - this.captcha?.reset(); + (_this$captcha = this.captcha) === null || _this$captcha === void 0 || _this$captcha.reset(); } }); this.form.addEventListener("reset", () => { @@ -736,21 +788,25 @@ * Add the solution of the proof of work and the captcha's answer. */ async protectSubmission(formData) { + var _this$captcha2; if (this.config.pow) { - const token = String(formData.get(FetchIt.tokenField) ?? ""); + var _formData$get; + const token = String((_formData$get = formData.get(FetchIt.tokenField)) !== null && _formData$get !== void 0 ? _formData$get : ""); formData.set(FetchIt.powField, await this.solution(token)); } - await this.captcha?.answer(formData); + await ((_this$captcha2 = this.captcha) === null || _this$captcha2 === void 0 ? void 0 : _this$captcha2.answer(formData)); } /** * The solution of the proof of work for a token, started once; solving * for another token stops it. */ solution(token) { - if (this.work?.token !== token) { - this.work?.stop.abort(); + var _this$work; + if (((_this$work = this.work) === null || _this$work === void 0 ? void 0 : _this$work.token) !== token) { + var _this$work2, _this$config$pow2; + (_this$work2 = this.work) === null || _this$work2 === void 0 || _this$work2.stop.abort(); const stop = new AbortController(); - const solution = solve(token, this.config.pow ?? 0, stop.signal); + const solution = solve(token, (_this$config$pow2 = this.config.pow) !== null && _this$config$pow2 !== void 0 ? _this$config$pow2 : 0, stop.signal); const work = { token, solution, @@ -768,9 +824,11 @@ * time the visitor sends it. */ solveAhead() { - const token = this.form.querySelector(`input[name="${FetchIt.tokenField}"]`)?.value; + var _this$form$querySelec; + const token = (_this$form$querySelec = this.form.querySelector(`input[name="${FetchIt.tokenField}"]`)) === null || _this$form$querySelec === void 0 ? void 0 : _this$form$querySelec.value; if (this.config.pow && token) this.solution(token).catch((error) => { - if (this.work?.token === token) console.error(error); + var _this$work3; + if (((_this$work3 = this.work) === null || _this$work3 === void 0 ? void 0 : _this$work3.token) === token) console.error(error); }); } /** @@ -841,7 +899,10 @@ }); } enableFields() { - this.elements.filter((field) => !this.disabledBefore?.includes(field)).forEach((field) => field.removeAttribute("disabled")); + this.elements.filter((field) => { + var _this$disabledBefore; + return !((_this$disabledBefore = this.disabledBefore) === null || _this$disabledBefore === void 0 ? void 0 : _this$disabledBefore.includes(field)); + }).forEach((field) => field.removeAttribute("disabled")); } disableFields() { this.disabledBefore = this.elements.filter((field) => field.hasAttribute("disabled")); @@ -890,7 +951,8 @@ */ static notify(hook, message) { try { - (FetchIt.Message?.[hook])?.(message); + var _FetchIt$Message, _FetchIt$Message$hook; + (_FetchIt$Message = FetchIt.Message) === null || _FetchIt$Message === void 0 || (_FetchIt$Message$hook = _FetchIt$Message[hook]) === null || _FetchIt$Message$hook === void 0 || _FetchIt$Message$hook.call(_FetchIt$Message, message); } catch (error) { console.error(`FetchIt: FetchIt.Message.${hook}() threw; the visitor did not get this notification`, error); } @@ -924,8 +986,24 @@ console.warn(`FetchIt: no form matches ${selector}`); return; } - forms.forEach((form) => FetchIt.instances.get(form) ?? new this(form, config)); + forms.forEach((form) => { + var _FetchIt$instances$ge; + return (_FetchIt$instances$ge = FetchIt.instances.get(form)) !== null && _FetchIt$instances$ge !== void 0 ? _FetchIt$instances$ge : new this(form, config); + }); } }; + _defineProperty(FetchIt, "forms", []); + _defineProperty(FetchIt, "instances", /* @__PURE__ */ new Map()); + _defineProperty(FetchIt, "defaultRequestErrorMessage", "Could not send the form. Please try again."); + _defineProperty(FetchIt, "tokenField", "fetchit_token"); + _defineProperty(FetchIt, "powField", "fetchit_pow"); + _defineProperty(FetchIt, "events", { + before: "fetchit:before", + success: "fetchit:success", + error: "fetchit:error", + after: "fetchit:after", + reset: "fetchit:reset" + }); + window.FetchIt = FetchIt; //#endregion })(); diff --git a/assets/components/fetchit/js/fetchit.min.js b/assets/components/fetchit/js/fetchit.min.js index 337bbcb..eb20b2d 100644 --- a/assets/components/fetchit/js/fetchit.min.js +++ b/assets/components/fetchit/js/fetchit.min.js @@ -1,4 +1,4 @@ -(function(){var e=class extends Error{name=`CaptchaError`};let t=1e4,n=3e4;async function r(e,n=t){for(let t=0;;t+=100){let r=e();if(r!==void 0||t>=n)return r;await new Promise(e=>setTimeout(e,100))}}function i(t,n,r){let i,a=new Promise((t,a)=>{i=setTimeout(()=>a(new e(`FetchIt: ${r} gave no answer in ${n/1e3} s`)),n)});return Promise.race([t,a]).finally(()=>clearTimeout(i))}function a(t,n){return new e(`FetchIt: the script of ${t} did not load; is ${n} blocked?`)}function o(e){try{e()}catch(e){console.error(e)}}function s(e){let t=document.createElement(`div`);t.className=`fetchit-captcha`;let n=e.querySelector(`[type="submit"]`);return n?n.before(t):e.append(t),t}function c(t,i){let c,l,u=e=>(c??=e.render(s(t),{sitekey:i,callback:()=>{l=void 0},"error-callback":e=>{l=String(e),console.error(`FetchIt: Turnstile error ${e}`)}}),c);return r(()=>window.turnstile).then(e=>e&&u(e)).catch(e=>console.error(e)),{async answer(t){let i=await r(()=>window.turnstile);if(!i)throw a(`Turnstile`,`challenges.cloudflare.com`);let o=u(i),s=await r(()=>{let t=i.getResponse(o);if(t)return t;if(l!==void 0)throw new e(`FetchIt: Turnstile failed with error ${l}`)},n);if(!s)throw new e(`FetchIt: Turnstile gave no answer in ${n/1e3} s`);t.set(`cf-turnstile-response`,s)},reset(){if(c!==void 0){let e=c;o(()=>window.turnstile?.reset(e))}}}}function l(o){return{async answer(s){let c=await r(()=>window.grecaptcha?.execute?window.grecaptcha:void 0),l=c?.execute;if(!c||!l)throw a(`reCAPTCHA`,`www.google.com`);await i(new Promise(e=>c.ready?c.ready(e):e()),t,`reCAPTCHA`);let u=await i(l(o,{action:`fetchit`}),n,`reCAPTCHA`).catch(t=>{throw t instanceof e?t:new e(`FetchIt: reCAPTCHA failed: ${String(t)}`)});s.set(`g-recaptcha-response`,u)},reset(){}}}function u(t,n){let c,l,u=(e,t)=>{let n=l;l=void 0,t?n?.reject(t):e&&n?.resolve(e)},d=r=>{if(c===void 0){let i=r.render(s(t),{sitekey:n,invisible:!0,callback:e=>u(e)});c=i,r.subscribe?.(i,`challenge-hidden`,()=>setTimeout(()=>{r.getResponse(i)||u(void 0,new e(`FetchIt: the check of SmartCaptcha was closed`))},1e3)),r.subscribe?.(i,`network-error`,()=>u(void 0,new e(`FetchIt: SmartCaptcha could not reach its server`))),r.subscribe?.(i,`javascript-error`,t=>u(void 0,new e(`FetchIt: SmartCaptcha failed: ${JSON.stringify(t)}`)))}return c};return r(()=>window.smartCaptcha).then(e=>e&&d(e)).catch(e=>console.error(e)),{async answer(e){let t=await r(()=>window.smartCaptcha);if(!t)throw a(`SmartCaptcha`,`smartcaptcha.yandexcloud.net`);let n=d(t),o=t.getResponse(n)||await i(new Promise((e,r)=>{l={resolve:e,reject:r},t.execute(n)}),18e4,`SmartCaptcha`);e.set(`smart-token`,o)},reset(){if(l=void 0,c!==void 0){let e=c;o(()=>window.smartCaptcha?.reset(e))}}}}function d(e,t){switch(t?.provider){case`turnstile`:return c(e,t.siteKey);case`recaptcha`:return l(t.siteKey);case`smartcaptcha`:return u(e,t.siteKey);default:return null}}function f(e=``){return e.replace(/(<([^>]+)>)/gi,``)}let p=`fetchit-toasts-style`,m=6e3,h=2**31-1,g=document.currentScript?.nonce||void 0,_=!1,v=null;function y(){if(document.getElementById(p))return;let e=document.createElement(`style`);e.id=p,g&&(e.nonce=g),e.textContent=` +(function(){function e(t){"@babel/helpers - typeof";return e=typeof Symbol==`function`&&typeof Symbol.iterator==`symbol`?function(e){return typeof e}:function(e){return e&&typeof Symbol==`function`&&e.constructor===Symbol&&e!==Symbol.prototype?`symbol`:typeof e},e(t)}function t(t,n){if(e(t)!=`object`||!t)return t;var r=t[Symbol.toPrimitive];if(r!==void 0){var i=r.call(t,n||`default`);if(e(i)!=`object`)return i;throw TypeError(`@@toPrimitive must return a primitive value.`)}return(n===`string`?String:Number)(t)}function n(n){var r=t(n,`string`);return e(r)==`symbol`?r:r+``}function r(e,t,r){return(t=n(t))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}var i=class extends Error{constructor(...e){super(...e),r(this,`name`,`CaptchaError`)}};let a=1e4,o=3e4;async function s(e,t=a){for(let n=0;;n+=100){let r=e();if(r!==void 0||n>=t)return r;await new Promise(e=>setTimeout(e,100))}}function c(e,t,n){let r,a=new Promise((e,a)=>{r=setTimeout(()=>a(new i(`FetchIt: ${n} gave no answer in ${t/1e3} s`)),t)});return Promise.race([e,a]).finally(()=>clearTimeout(r))}function l(e,t){return new i(`FetchIt: the script of ${e} did not load; is ${t} blocked?`)}function u(e){try{e()}catch(e){console.error(e)}}function d(e){let t=document.createElement(`div`);t.className=`fetchit-captcha`;let n=e.querySelector(`[type="submit"]`);return n?n.before(t):e.append(t),t}function f(e,t){let n,r,a=i=>(n!=null||(n=i.render(d(e),{sitekey:t,callback:()=>{r=void 0},"error-callback":e=>{r=String(e),console.error(`FetchIt: Turnstile error ${e}`)}})),n);return s(()=>window.turnstile).then(e=>e&&a(e)).catch(e=>console.error(e)),{async answer(e){let t=await s(()=>window.turnstile);if(!t)throw l(`Turnstile`,`challenges.cloudflare.com`);let n=a(t),c=await s(()=>{let e=t.getResponse(n);if(e)return e;if(r!==void 0)throw new i(`FetchIt: Turnstile failed with error ${r}`)},o);if(!c)throw new i(`FetchIt: Turnstile gave no answer in ${o/1e3} s`);e.set(`cf-turnstile-response`,c)},reset(){if(n!==void 0){let e=n;u(()=>{var t;return(t=window.turnstile)==null?void 0:t.reset(e)})}}}}function p(e){return{async answer(t){let n=await s(()=>{var e;return(e=window.grecaptcha)!=null&&e.execute?window.grecaptcha:void 0}),r=n==null?void 0:n.execute;if(!n||!r)throw l(`reCAPTCHA`,`www.google.com`);await c(new Promise(e=>n.ready?n.ready(e):e()),a,`reCAPTCHA`);let u=await c(r(e,{action:`fetchit`}),o,`reCAPTCHA`).catch(e=>{throw e instanceof i?e:new i(`FetchIt: reCAPTCHA failed: ${String(e)}`)});t.set(`g-recaptcha-response`,u)},reset(){}}}function m(e,t){let n,r,a=(e,t)=>{let n=r;r=void 0,t?n==null||n.reject(t):e&&(n==null||n.resolve(e))},o=r=>{if(n===void 0){var o,s,c;let l=r.render(d(e),{sitekey:t,invisible:!0,callback:e=>a(e)});n=l,(o=r.subscribe)==null||o.call(r,l,`challenge-hidden`,()=>setTimeout(()=>{r.getResponse(l)||a(void 0,new i(`FetchIt: the check of SmartCaptcha was closed`))},1e3)),(s=r.subscribe)==null||s.call(r,l,`network-error`,()=>a(void 0,new i(`FetchIt: SmartCaptcha could not reach its server`))),(c=r.subscribe)==null||c.call(r,l,`javascript-error`,e=>a(void 0,new i(`FetchIt: SmartCaptcha failed: ${JSON.stringify(e)}`)))}return n};return s(()=>window.smartCaptcha).then(e=>e&&o(e)).catch(e=>console.error(e)),{async answer(e){let t=await s(()=>window.smartCaptcha);if(!t)throw l(`SmartCaptcha`,`smartcaptcha.yandexcloud.net`);let n=o(t),i=t.getResponse(n)||await c(new Promise((e,i)=>{r={resolve:e,reject:i},t.execute(n)}),18e4,`SmartCaptcha`);e.set(`smart-token`,i)},reset(){if(r=void 0,n!==void 0){let e=n;u(()=>{var t;return(t=window.smartCaptcha)==null?void 0:t.reset(e)})}}}}function h(e,t){switch(t==null?void 0:t.provider){case`turnstile`:return f(e,t.siteKey);case`recaptcha`:return p(t.siteKey);case`smartcaptcha`:return m(e,t.siteKey);default:return null}}function g(e=``){return e.replace(/(<([^>]+)>)/gi,``)}var _;let v=`fetchit-toasts-style`,y=6e3,b=2**31-1,x=((_=document.currentScript)==null?void 0:_.nonce)||void 0,S=!1,C=null;function w(){var e;if(document.getElementById(v))return;let t=document.createElement(`style`);t.id=v,x&&(t.nonce=x),t.textContent=` .fetchit-toasts { position: fixed; z-index: 2147483000; inset-block-end: 1rem; inset-inline-end: 1rem; display: flex; flex-direction: column; gap: .5rem; @@ -36,4 +36,4 @@ @keyframes fetchit-toast-in { from { opacity: 0; transform: translateY(.5rem); } } @media (prefers-reduced-motion: reduce) { .fetchit-toast { animation: none; } } @media (max-width: 30rem) { .fetchit-toasts { inset-inline: 1rem; width: auto; max-width: none; } } -`,(document.head??document.documentElement).prepend(e),!e.sheet&&!_&&(_=!0,console.warn(`FetchIt: the styles of the notifier were blocked, probably by a Content-Security-Policy (style-src). Give the FetchIt script a nonce, or style .fetchit-toast yourself.`))}function b(e){Object.assign(e.style,{position:`absolute`,width:`1px`,height:`1px`,margin:`-1px`,padding:`0`,overflow:`hidden`,clip:`rect(0 0 0 0)`,whiteSpace:`nowrap`,border:`0`})}function x(){let e=document.body??document.documentElement,t=document.querySelector(`.fetchit-toasts`);t||(t=document.createElement(`div`),t.className=`fetchit-toasts`,e.append(t));let n=t=>{let n=document.querySelector(`.fetchit-toasts-live[role="${t}"]`);return n||(n=document.createElement(`div`),n.className=`fetchit-toasts-live`,n.setAttribute(`role`,t),b(n),e.append(n)),n};return{toasts:t,polite:n(`status`),assertive:n(`alert`)}}function S(e,t){e.textContent=``,setTimeout(()=>{e.textContent=t},100)}function C(e){return e===void 0?m:e===0||e===1/0?0:Number.isFinite(e)&&e>0?Math.min(e,h):(console.warn(`FetchIt: createNotifier() got duration ${e}; using ${m}`),m)}function w(e){return e instanceof HTMLElement&&e.isConnected&&!e.hasAttribute(`disabled`)}function T(e,t,n,r){let i=f(t==null?``:String(t)).trim();if(i===``)return;y();let{toasts:a,polite:o,assertive:s}=x(),c=document.createElement(`div`);c.className=`fetchit-toast`,c.dataset.type=e;let l=document.createElement(`div`);l.className=`fetchit-toast__text`,l.textContent=i;let u=document.createElement(`button`);u.type=`button`,u.className=`fetchit-toast__close`,u.setAttribute(`aria-label`,n),u.textContent=`×`;let d,p=!1,m=!1,h=()=>{if(clearTimeout(d),c.contains(document.activeElement)){let e=Array.from(a.querySelectorAll(`.fetchit-toast__close`)).filter(e=>!c.contains(e)),t=e.find(e=>c.compareDocumentPosition(e)&Node.DOCUMENT_POSITION_FOLLOWING)??e.at(-1);t?t.focus():w(v)?v.focus():document.activeElement?.blur()}c.remove()},g=()=>{clearTimeout(d),r>0&&!p&&!m&&(d=setTimeout(h,r))},_=()=>clearTimeout(d);u.addEventListener(`click`,h),c.addEventListener(`mouseenter`,()=>{p=!0,_()}),c.addEventListener(`mouseleave`,()=>{p=!1,g()}),c.addEventListener(`focusin`,e=>{let t=e.relatedTarget;(!t||!a.contains(t))&&(v=t),m=!0,_()}),c.addEventListener(`focusout`,e=>{c.contains(e.relatedTarget)||(m=!1,g())}),c.append(l,u),a.append(c);for(let e of Array.from(a.children)){if(a.children.length<=3)break;e.contains(document.activeElement)||e.remove()}S(e===`error`?s:o,i),g()}function E(e={}){let t=e.closeLabel||`Close`,n=C(e.duration);return document.body&&x(),{success(e){T(`success`,e,t,n)},error(e){T(`error`,e,t,n)}}}let D=new Uint32Array([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]),O=new TextEncoder,k=(e,t)=>e>>>t|e<<32-t;function A(e){let t=O.encode(e),n=Math.ceil((t.length+9)/64),r=new Uint32Array(n*16);for(let e=0;e>2]|=t[e]<<24-e%4*8;r[t.length>>2]|=128<<24-t.length%4*8,r[r.length-1]=t.length*8;let i=new Uint32Array([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]),a=/* @__PURE__ */ new Uint32Array(64);for(let e=0;e>>3,i=k(n,17)^k(n,19)^n>>>10;a[e]=a[e-16]+r+a[e-7]+i|0}let t=i[0],n=i[1],o=i[2],s=i[3],c=i[4],l=i[5],u=i[6],d=i[7];for(let e=0;e<64;e++){let r=d+(k(c,6)^k(c,11)^k(c,25))+(c&l^~c&u)+D[e]+a[e]|0,i=(k(t,2)^k(t,13)^k(t,22))+(t&n^t&o^n&o)|0;d=u,u=l,l=c,c=s+r|0,s=o,o=n,n=t,t=r+i|0}i[0]+=t,i[1]+=n,i[2]+=o,i[3]+=s,i[4]+=c,i[5]+=l,i[6]+=u,i[7]+=d}return i}function j(e){let t=0;for(let n of e){if(n===0){t+=32;continue}return t+Math.clz32(n)}return t}async function M(e,t,n,r=2e3){for(let i=0;;i++){if(j(A(`${e}:${i}`))>=t)return String(i);if(i%r===r-1&&(await new Promise(e=>setTimeout(e,0)),n?.aborted))throw Error(`FetchIt: the proof of work was stopped`)}}function N(e,t,n=!0){return document.dispatchEvent(new CustomEvent(e,{cancelable:n,detail:t}))}function P(e){let t=e.message,n=e.data;return{success:e.success,message:typeof t==`string`?t:t==null?``:String(t),data:typeof n==`object`&&n?n:{}}}window.FetchIt=class t{static forms=[];static instances=/* @__PURE__ */ new Map;static defaultRequestErrorMessage=`Could not send the form. Please try again.`;static tokenField=`fetchit_token`;static powField=`fetchit_pow`;static events={before:`fetchit:before`,success:`fetchit:success`,error:`fetchit:error`,after:`fetchit:after`,reset:`fetchit:reset`};formData=void 0;constructor(e,n){if(!(e instanceof HTMLFormElement))throw Error(`FetchIt: the element is not a form`);this.form=e,this.config=n,this.request=new Request(this.config.actionUrl,{method:`post`,credentials:`same-origin`,headers:{Accept:`application/json`,"X-FetchIt-Action":this.config.action}}),this.captcha=d(this.form,this.config.captcha),this.prepareEvents(),t.forms.push(this.form),t.instances.set(this.form,this)}prepareEvents(){this.form.addEventListener(`submit`,async e=>{if(e.preventDefault(),this.pending)return;let n=new FormData(this.form);if(n.set(`pageId`,String(this.config.pageId)),this.formData=n,this.clearErrors(),this.clearFormMessages(),t.notify(`before`),!N(`fetchit:before`,{form:this.form,formData:n,fetchit:this}))return;this.pending=!0,this.disableFields();let r=!1,i;try{try{await this.protectSubmission(n);let e=await fetch(this.request,{method:`post`,body:n}),r=e.headers?.get(`X-FetchIt-Token`);this.updateToken(r);let a=e.headers?.get(`X-FetchIt-Refused`),o=Number(e.headers?.get(`X-FetchIt-Pow`)??0);r&&(a===`token`||a===`pow`&&o>(this.config.pow??0))&&(a===`pow`&&(this.config.pow=o),n.set(t.tokenField,r),this.config.pow&&n.set(t.powField,await this.solution(r)),e=await fetch(this.request,{method:`post`,body:n}),r=e.headers?.get(`X-FetchIt-Token`),this.updateToken(r)),a===`captcha`&&!this.config.captcha&&console.warn(`FetchIt: the server asks for a captcha this page does not have; the page may come from a cache made before the captcha was turned on`);let s=await e.json();if(!t.isResponse(s))throw Error(`FetchIt: unexpected answer from ${e.url||this.config.actionUrl} (HTTP ${e.status})`);i=P(s)}catch(e){this.failRequest(e,n);return}if(t.notify(`after`,i.message),!N(`fetchit:after`,{form:this.form,formData:n,response:i,fetchit:this}))return;if(!i.success){if(t.notify(`error`,i.message),!N(`fetchit:error`,{form:this.form,formData:n,response:i,fetchit:this}))return;for(let[e,n]of Object.entries(i.data))t.hasErrorMessage(n)&&this.setError(e,n);this.setFormMessage(`validation`,i.message),r=!0;return}if(this.clearErrors(),this.setFormMessage(`success`,i.message),r=!0,t.notify(`success`,i.message),!N(`fetchit:success`,{form:this.form,formData:n,response:i,fetchit:this}))return;if(this.config.captcha?.provider!==`recaptcha`)try{window.grecaptcha?.reset?.()}catch(e){console.error(e)}this.config.clearFieldsOnSuccess&&(this.preserveFormMessagesOnReset=!0,this.form.reset(),this.preserveFormMessagesOnReset=!1)}catch(e){r||i?.success?console.error(e):this.failRequest(e,n)}finally{this.enableFields(),this.pending=!1,this.captcha?.reset()}}),this.form.addEventListener(`reset`,()=>{N(`fetchit:reset`,{form:this.form,fetchit:this},!1),this.clearErrors(),this.preserveFormMessagesOnReset||this.clearFormMessages(),t.notify(`reset`)}),[`change`,`input`].forEach(e=>{this.form.addEventListener(e,({target:e})=>{this.clearError(e.getAttribute(`name`))})}),this.form.addEventListener(`focusin`,()=>this.solveAhead(),{once:!0})}failRequest(n,r){console.error(n);let i=n instanceof e&&this.config.captchaErrorMessage||this.config.requestErrorMessage||t.defaultRequestErrorMessage;t.notify(`error`,i),N(`fetchit:error`,{form:this.form,formData:r,response:null,error:n,fetchit:this})&&this.setFormMessage(`validation`,i)}async protectSubmission(e){if(this.config.pow){let n=String(e.get(t.tokenField)??``);e.set(t.powField,await this.solution(n))}await this.captcha?.answer(e)}solution(e){if(this.work?.token!==e){this.work?.stop.abort();let t=new AbortController,n=M(e,this.config.pow??0,t.signal),r={token:e,solution:n,stop:t};this.work=r,n.catch(()=>{this.work===r&&(this.work=void 0)})}return this.work.solution}solveAhead(){let e=this.form.querySelector(`input[name="${t.tokenField}"]`)?.value;this.config.pow&&e&&this.solution(e).catch(t=>{this.work?.token===e&&console.error(t)})}updateToken(e){e&&(this.form.querySelectorAll(`input[name="${t.tokenField}"]`).forEach(t=>{t.value=e,t.defaultValue=e}),this.solveAhead())}clearErrors(){this.fields.forEach(e=>this.clearError(e.getAttribute(`name`)))}clearError(e){let t=this.getFields(e);t.forEach(e=>{this.inputInvalidClasses&&e.classList.remove(...this.inputInvalidClasses),e.removeAttribute(`aria-invalid`)});let n=this.getErrors(e);n.forEach(e=>{e.style.display=`none`,e.innerHTML=``});let r=this.getCustomErrors(e);return this.customInvalidClasses&&r.forEach(({classList:e})=>e.remove(...this.customInvalidClasses)),{fields:t,errors:n,customErrors:r}}setError(e,n=``){t.hasErrorMessage(n)&&(this.getFields(e).forEach(e=>{this.inputInvalidClasses&&e.classList.add(...this.inputInvalidClasses),e.setAttribute(`aria-invalid`,`true`)}),this.customInvalidClasses&&this.getCustomErrors(e).forEach(({classList:e})=>e.add(...this.customInvalidClasses)),this.getErrors(e).forEach(e=>{let r=t.sanitizeHTML(String(n)).trim();e.style.display=``,e.textContent=r}))}clearFormMessages(){this.form.querySelectorAll(`[data-success], [data-validation-error]`).forEach(e=>{e.style.display=`none`,e.textContent=``})}setFormMessage(e,n=``){let r=t.sanitizeHTML(String(n)).trim();if(r===``)return;let i=e===`success`?`[data-success]`:`[data-validation-error]`,a=e===`success`?`[data-validation-error]`:`[data-success]`;this.form.querySelectorAll(a).forEach(e=>{e.style.display=`none`,e.textContent=``}),this.form.querySelectorAll(i).forEach(e=>{e.style.display=``,e.textContent=r})}enableFields(){this.elements.filter(e=>!this.disabledBefore?.includes(e)).forEach(e=>e.removeAttribute(`disabled`))}disableFields(){this.disabledBefore=this.elements.filter(e=>e.hasAttribute(`disabled`)),this.elements.forEach(e=>e.setAttribute(`disabled`,``))}getFields(e){if(!e)return[];let n=t.escapeAttribute(e);return Array.from(this.form.querySelectorAll(`[name="${n}"], [name="${n}[]"]`))}getErrors(e){if(!e)return[];let n=t.escapeAttribute(e);return Array.from(this.form.querySelectorAll(`[data-error="${n}"], [data-error="${n}[]"]`))}getCustomErrors(e){return e?Array.from(this.form.querySelectorAll(`[data-custom="${t.escapeAttribute(e)}"]`)):[]}get elements(){return Array.from(this.form.elements)}get fields(){return this.elements.filter(({tagName:e})=>[`select`,`input`,`textarea`].includes(e.toLowerCase()))}get inputInvalidClasses(){return this.config.inputInvalidClass?this.config.inputInvalidClass.split(` `):[]}get customInvalidClasses(){return this.config.customInvalidClass?this.config.customInvalidClass.split(` `):[]}static escapeAttribute(e){return e.replace(/["\\]/g,`\\$&`)}static notify(e,n){try{t.Message?.[e]?.(n)}catch(t){console.error(`FetchIt: FetchIt.Message.${e}() threw; the visitor did not get this notification`,t)}}static isResponse(e){return typeof e==`object`&&!!e&&typeof e.success==`boolean`}static sanitizeHTML(e=``){return f(e)}static hasErrorMessage(e=``){return t.sanitizeHTML(String(e)).trim()!==``}static createNotifier(e){return E(e)}static create(e){if(e.defaultNotifier){let n=t.Message;n===void 0?t.Message=E({closeLabel:e.notifierCloseLabel}):!n.success&&!n.error&&Object.assign(n,E({closeLabel:e.notifierCloseLabel}))}if(!e.action)throw Error(`FetchIt: the config has no action`);let n=`form[data-fetchit="${t.escapeAttribute(e.action)}"]`,r=document.querySelectorAll(n);if(!r.length){console.warn(`FetchIt: no form matches ${n}`);return}r.forEach(n=>t.instances.get(n)??new this(n,e))}}})(); \ No newline at end of file +`,((e=document.head)==null?document.documentElement:e).prepend(t),!t.sheet&&!S&&(S=!0,console.warn(`FetchIt: the styles of the notifier were blocked, probably by a Content-Security-Policy (style-src). Give the FetchIt script a nonce, or style .fetchit-toast yourself.`))}function T(e){Object.assign(e.style,{position:`absolute`,width:`1px`,height:`1px`,margin:`-1px`,padding:`0`,overflow:`hidden`,clip:`rect(0 0 0 0)`,whiteSpace:`nowrap`,border:`0`})}function E(){var e;let t=(e=document.body)==null?document.documentElement:e,n=document.querySelector(`.fetchit-toasts`);n||(n=document.createElement(`div`),n.className=`fetchit-toasts`,t.append(n));let r=e=>{let n=document.querySelector(`.fetchit-toasts-live[role="${e}"]`);return n||(n=document.createElement(`div`),n.className=`fetchit-toasts-live`,n.setAttribute(`role`,e),T(n),t.append(n)),n};return{toasts:n,polite:r(`status`),assertive:r(`alert`)}}function D(e,t){e.textContent=``,setTimeout(()=>{e.textContent=t},100)}function O(e){return e===void 0?y:e===0||e===1/0?0:Number.isFinite(e)&&e>0?Math.min(e,b):(console.warn(`FetchIt: createNotifier() got duration ${e}; using ${y}`),y)}function k(e){return e instanceof HTMLElement&&e.isConnected&&!e.hasAttribute(`disabled`)}function A(e,t,n,r){let i=g(t==null?``:String(t)).trim();if(i===``)return;w();let{toasts:a,polite:o,assertive:s}=E(),c=document.createElement(`div`);c.className=`fetchit-toast`,c.dataset.type=e;let l=document.createElement(`div`);l.className=`fetchit-toast__text`,l.textContent=i;let u=document.createElement(`button`);u.type=`button`,u.className=`fetchit-toast__close`,u.setAttribute(`aria-label`,n),u.textContent=`×`;let d,f=!1,p=!1,m=()=>{if(clearTimeout(d),c.contains(document.activeElement)){var e;let n=Array.from(a.querySelectorAll(`.fetchit-toast__close`)).filter(e=>!c.contains(e)),r=(e=n.find(e=>c.compareDocumentPosition(e)&Node.DOCUMENT_POSITION_FOLLOWING))==null?n.at(-1):e;if(r)r.focus();else if(k(C))C.focus();else{var t;(t=document.activeElement)==null||t.blur()}}c.remove()},h=()=>{clearTimeout(d),r>0&&!f&&!p&&(d=setTimeout(m,r))},_=()=>clearTimeout(d);u.addEventListener(`click`,m),c.addEventListener(`mouseenter`,()=>{f=!0,_()}),c.addEventListener(`mouseleave`,()=>{f=!1,h()}),c.addEventListener(`focusin`,e=>{let t=e.relatedTarget;(!t||!a.contains(t))&&(C=t),p=!0,_()}),c.addEventListener(`focusout`,e=>{c.contains(e.relatedTarget)||(p=!1,h())}),c.append(l,u),a.append(c);for(let e of Array.from(a.children)){if(a.children.length<=3)break;e.contains(document.activeElement)||e.remove()}D(e===`error`?s:o,i),h()}function j(e={}){let t=e.closeLabel||`Close`,n=O(e.duration);return document.body&&E(),{success(e){A(`success`,e,t,n)},error(e){A(`error`,e,t,n)}}}let M=new Uint32Array([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]),N=new TextEncoder,P=(e,t)=>e>>>t|e<<32-t;function F(e){let t=N.encode(e),n=Math.ceil((t.length+9)/64),r=new Uint32Array(n*16);for(let e=0;e>2]|=t[e]<<24-e%4*8;r[t.length>>2]|=128<<24-t.length%4*8,r[r.length-1]=t.length*8;let i=new Uint32Array([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]),a=/* @__PURE__ */ new Uint32Array(64);for(let e=0;e>>3,i=P(n,17)^P(n,19)^n>>>10;a[e]=a[e-16]+r+a[e-7]+i|0}let t=i[0],n=i[1],o=i[2],s=i[3],c=i[4],l=i[5],u=i[6],d=i[7];for(let e=0;e<64;e++){let r=d+(P(c,6)^P(c,11)^P(c,25))+(c&l^~c&u)+M[e]+a[e]|0,i=(P(t,2)^P(t,13)^P(t,22))+(t&n^t&o^n&o)|0;d=u,u=l,l=c,c=s+r|0,s=o,o=n,n=t,t=r+i|0}i[0]+=t,i[1]+=n,i[2]+=o,i[3]+=s,i[4]+=c,i[5]+=l,i[6]+=u,i[7]+=d}return i}function I(e){let t=0;for(let n of e){if(n===0){t+=32;continue}return t+Math.clz32(n)}return t}async function L(e,t,n,r=2e3){for(let i=0;;i++){if(I(F(`${e}:${i}`))>=t)return String(i);if(i%r===r-1&&(await new Promise(e=>setTimeout(e,0)),n!=null&&n.aborted))throw Error(`FetchIt: the proof of work was stopped`)}}function R(e,t,n=!0){return document.dispatchEvent(new CustomEvent(e,{cancelable:n,detail:t}))}function z(e){let t=e.message,n=e.data;return{success:e.success,message:typeof t==`string`?t:t==null?``:String(t),data:typeof n==`object`&&n?n:{}}}var B=class e{constructor(t,n){if(r(this,`formData`,void 0),!(t instanceof HTMLFormElement))throw Error(`FetchIt: the element is not a form`);this.form=t,this.config=n,this.request=new Request(this.config.actionUrl,{method:`post`,credentials:`same-origin`,headers:{Accept:`application/json`,"X-FetchIt-Action":this.config.action}}),this.captcha=h(this.form,this.config.captcha),this.prepareEvents(),e.forms.push(this.form),e.instances.set(this.form,this)}prepareEvents(){this.form.addEventListener(`submit`,async t=>{if(t.preventDefault(),this.pending)return;let n=new FormData(this.form);if(n.set(`pageId`,String(this.config.pageId)),this.formData=n,this.clearErrors(),this.clearFormMessages(),e.notify(`before`),!R(`fetchit:before`,{form:this.form,formData:n,fetchit:this}))return;this.pending=!0,this.disableFields();let r=!1,i;try{var a;try{var o,s,c,l,u;await this.protectSubmission(n);let t=await fetch(this.request,{method:`post`,body:n}),r=(o=t.headers)==null?void 0:o.get(`X-FetchIt-Token`);this.updateToken(r);let a=(s=t.headers)==null?void 0:s.get(`X-FetchIt-Refused`),f=Number((c=(l=t.headers)==null?void 0:l.get(`X-FetchIt-Pow`))==null?0:c);if(r&&(a===`token`||a===`pow`&&f>((u=this.config.pow)==null?0:u))){var d;a===`pow`&&(this.config.pow=f),n.set(e.tokenField,r),this.config.pow&&n.set(e.powField,await this.solution(r)),t=await fetch(this.request,{method:`post`,body:n}),r=(d=t.headers)==null?void 0:d.get(`X-FetchIt-Token`),this.updateToken(r)}a===`captcha`&&!this.config.captcha&&console.warn(`FetchIt: the server asks for a captcha this page does not have; the page may come from a cache made before the captcha was turned on`);let p=await t.json();if(!e.isResponse(p))throw Error(`FetchIt: unexpected answer from ${t.url||this.config.actionUrl} (HTTP ${t.status})`);i=z(p)}catch(e){this.failRequest(e,n);return}if(e.notify(`after`,i.message),!R(`fetchit:after`,{form:this.form,formData:n,response:i,fetchit:this}))return;if(!i.success){if(e.notify(`error`,i.message),!R(`fetchit:error`,{form:this.form,formData:n,response:i,fetchit:this}))return;for(let[t,n]of Object.entries(i.data))e.hasErrorMessage(n)&&this.setError(t,n);this.setFormMessage(`validation`,i.message),r=!0;return}if(this.clearErrors(),this.setFormMessage(`success`,i.message),r=!0,e.notify(`success`,i.message),!R(`fetchit:success`,{form:this.form,formData:n,response:i,fetchit:this}))return;if(((a=this.config.captcha)==null?void 0:a.provider)!==`recaptcha`)try{var f,p;(f=window.grecaptcha)==null||(p=f.reset)==null||p.call(f)}catch(e){console.error(e)}this.config.clearFieldsOnSuccess&&(this.preserveFormMessagesOnReset=!0,this.form.reset(),this.preserveFormMessagesOnReset=!1)}catch(e){r||i!=null&&i.success?console.error(e):this.failRequest(e,n)}finally{var m;this.enableFields(),this.pending=!1,(m=this.captcha)==null||m.reset()}}),this.form.addEventListener(`reset`,()=>{R(`fetchit:reset`,{form:this.form,fetchit:this},!1),this.clearErrors(),this.preserveFormMessagesOnReset||this.clearFormMessages(),e.notify(`reset`)}),[`change`,`input`].forEach(e=>{this.form.addEventListener(e,({target:e})=>{this.clearError(e.getAttribute(`name`))})}),this.form.addEventListener(`focusin`,()=>this.solveAhead(),{once:!0})}failRequest(t,n){console.error(t);let r=t instanceof i&&this.config.captchaErrorMessage||this.config.requestErrorMessage||e.defaultRequestErrorMessage;e.notify(`error`,r),R(`fetchit:error`,{form:this.form,formData:n,response:null,error:t,fetchit:this})&&this.setFormMessage(`validation`,r)}async protectSubmission(t){var n;if(this.config.pow){var r;let n=String((r=t.get(e.tokenField))==null?``:r);t.set(e.powField,await this.solution(n))}await((n=this.captcha)==null?void 0:n.answer(t))}solution(e){var t;if(((t=this.work)==null?void 0:t.token)!==e){var n,r;(n=this.work)==null||n.stop.abort();let t=new AbortController,i=L(e,(r=this.config.pow)==null?0:r,t.signal),a={token:e,solution:i,stop:t};this.work=a,i.catch(()=>{this.work===a&&(this.work=void 0)})}return this.work.solution}solveAhead(){var t;let n=(t=this.form.querySelector(`input[name="${e.tokenField}"]`))==null?void 0:t.value;this.config.pow&&n&&this.solution(n).catch(e=>{var t;((t=this.work)==null?void 0:t.token)===n&&console.error(e)})}updateToken(t){t&&(this.form.querySelectorAll(`input[name="${e.tokenField}"]`).forEach(e=>{e.value=t,e.defaultValue=t}),this.solveAhead())}clearErrors(){this.fields.forEach(e=>this.clearError(e.getAttribute(`name`)))}clearError(e){let t=this.getFields(e);t.forEach(e=>{this.inputInvalidClasses&&e.classList.remove(...this.inputInvalidClasses),e.removeAttribute(`aria-invalid`)});let n=this.getErrors(e);n.forEach(e=>{e.style.display=`none`,e.innerHTML=``});let r=this.getCustomErrors(e);return this.customInvalidClasses&&r.forEach(({classList:e})=>e.remove(...this.customInvalidClasses)),{fields:t,errors:n,customErrors:r}}setError(t,n=``){e.hasErrorMessage(n)&&(this.getFields(t).forEach(e=>{this.inputInvalidClasses&&e.classList.add(...this.inputInvalidClasses),e.setAttribute(`aria-invalid`,`true`)}),this.customInvalidClasses&&this.getCustomErrors(t).forEach(({classList:e})=>e.add(...this.customInvalidClasses)),this.getErrors(t).forEach(t=>{let r=e.sanitizeHTML(String(n)).trim();t.style.display=``,t.textContent=r}))}clearFormMessages(){this.form.querySelectorAll(`[data-success], [data-validation-error]`).forEach(e=>{e.style.display=`none`,e.textContent=``})}setFormMessage(t,n=``){let r=e.sanitizeHTML(String(n)).trim();if(r===``)return;let i=t===`success`?`[data-success]`:`[data-validation-error]`,a=t===`success`?`[data-validation-error]`:`[data-success]`;this.form.querySelectorAll(a).forEach(e=>{e.style.display=`none`,e.textContent=``}),this.form.querySelectorAll(i).forEach(e=>{e.style.display=``,e.textContent=r})}enableFields(){this.elements.filter(e=>{var t;return!((t=this.disabledBefore)!=null&&t.includes(e))}).forEach(e=>e.removeAttribute(`disabled`))}disableFields(){this.disabledBefore=this.elements.filter(e=>e.hasAttribute(`disabled`)),this.elements.forEach(e=>e.setAttribute(`disabled`,``))}getFields(t){if(!t)return[];let n=e.escapeAttribute(t);return Array.from(this.form.querySelectorAll(`[name="${n}"], [name="${n}[]"]`))}getErrors(t){if(!t)return[];let n=e.escapeAttribute(t);return Array.from(this.form.querySelectorAll(`[data-error="${n}"], [data-error="${n}[]"]`))}getCustomErrors(t){return t?Array.from(this.form.querySelectorAll(`[data-custom="${e.escapeAttribute(t)}"]`)):[]}get elements(){return Array.from(this.form.elements)}get fields(){return this.elements.filter(({tagName:e})=>[`select`,`input`,`textarea`].includes(e.toLowerCase()))}get inputInvalidClasses(){return this.config.inputInvalidClass?this.config.inputInvalidClass.split(` `):[]}get customInvalidClasses(){return this.config.customInvalidClass?this.config.customInvalidClass.split(` `):[]}static escapeAttribute(e){return e.replace(/["\\]/g,`\\$&`)}static notify(t,n){try{var r,i;(r=e.Message)==null||(i=r[t])==null||i.call(r,n)}catch(e){console.error(`FetchIt: FetchIt.Message.${t}() threw; the visitor did not get this notification`,e)}}static isResponse(e){return typeof e==`object`&&!!e&&typeof e.success==`boolean`}static sanitizeHTML(e=``){return g(e)}static hasErrorMessage(t=``){return e.sanitizeHTML(String(t)).trim()!==``}static createNotifier(e){return j(e)}static create(t){if(t.defaultNotifier){let n=e.Message;n===void 0?e.Message=j({closeLabel:t.notifierCloseLabel}):!n.success&&!n.error&&Object.assign(n,j({closeLabel:t.notifierCloseLabel}))}if(!t.action)throw Error(`FetchIt: the config has no action`);let n=`form[data-fetchit="${e.escapeAttribute(t.action)}"]`,r=document.querySelectorAll(n);if(!r.length){console.warn(`FetchIt: no form matches ${n}`);return}r.forEach(n=>{var r;return(r=e.instances.get(n))==null?new this(n,t):r})}};r(B,`forms`,[]),r(B,`instances`,/* @__PURE__ */ new Map),r(B,`defaultRequestErrorMessage`,`Could not send the form. Please try again.`),r(B,`tokenField`,`fetchit_token`),r(B,`powField`,`fetchit_pow`),r(B,`events`,{before:`fetchit:before`,success:`fetchit:success`,error:`fetchit:error`,after:`fetchit:after`,reset:`fetchit:reset`}),window.FetchIt=B})(); \ No newline at end of file diff --git a/core/components/fetchit/docs/readme.txt b/core/components/fetchit/docs/readme.txt index 3506fb0..54885a0 100644 --- a/core/components/fetchit/docs/readme.txt +++ b/core/components/fetchit/docs/readme.txt @@ -5,4 +5,7 @@ Author: Gulomov Bakhtovar Original author: Vasily Naumkin -------------------- -A lightweight Extra for MODX Revolution for submitting forms with Fetch API. +A lightweight extra for MODX Revolution 2.8 and 3 that submits forms with the Fetch API, +without a page reload, with FormIt or a snippet of your own, and protects them from spam. + +Documentation: https://github.com/GulomovCreative/FetchIt#readme diff --git a/package-lock.json b/package-lock.json index 885dcd6..91c3a5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "devDependencies": { "@playwright/test": "^1.63.0", "@types/node": "^26.6.2", + "acorn": "^8.18.0", "jsdom": "^30.1.1", "oxlint": "^1.85.0", "rolldown": "^1.2.10", @@ -1319,6 +1320,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", diff --git a/package.json b/package.json index 6b30f22..26c5205 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,14 @@ "scripts": { "build": "rolldown -c", "build:check": "npm run build && git diff --exit-code -- assets/components/fetchit/js", + "check:syntax": "acorn --ecma2019 --silent assets/components/fetchit/js/fetchit.js && acorn --ecma2019 --silent assets/components/fetchit/js/fetchit.min.js", "lint": "oxlint", "test": "vitest run", "test:watch": "vitest", "e2e": "playwright test", "typecheck": "tsc -p . && tsc -p tests/types", - "screenshots": "npm run build && node _build/screenshots.mjs" + "screenshots": "npm run build && node _build/screenshots.mjs", + "logo": "node _build/logo.mjs" }, "keywords": [ "MODX Extra" @@ -31,6 +33,7 @@ "devDependencies": { "@playwright/test": "^1.63.0", "@types/node": "^26.6.2", + "acorn": "^8.18.0", "jsdom": "^30.1.1", "oxlint": "^1.85.0", "rolldown": "^1.2.10", diff --git a/rolldown.config.ts b/rolldown.config.ts index 3949eb3..f80e1f8 100644 --- a/rolldown.config.ts +++ b/rolldown.config.ts @@ -17,6 +17,10 @@ const copyTypes = { export default defineConfig({ input: 'src/index.ts', + // The browsers of browserslist in package.json include Chromium 78 (UC + // Browser, Opera Mobile), which has no ?., ?? or ??=: lower the syntax to + // ES2019, which every browser there runs. + transform: { target: 'es2019' }, plugins: [copyTypes], output: [ { diff --git a/src/fetchit.d.ts b/src/fetchit.d.ts index 61bbf9d..609e233 100644 --- a/src/fetchit.d.ts +++ b/src/fetchit.d.ts @@ -72,9 +72,9 @@ interface FetchItAnswer { /** * Hooks for notifications: set FetchIt.Message to show the answers your - * way. They run before the event of the same moment is dispatched, so - * cancelling the event does not undo them. An exception in a hook is logged - * and does not stop the form. + * way. They run before the event of the same moment is dispatched (reset + * after fetchit:reset), so cancelling the event does not undo them. An + * exception in a hook is logged and does not stop the form. */ interface FetchItMessage { /** On submit, before fetchit:before */ @@ -85,7 +85,7 @@ interface FetchItMessage { success?: ((message: string) => void) | undefined; /** When the form was refused or could not be sent, before fetchit:error */ error?: ((message: string) => void) | undefined; - /** On every reset of the form, the one after a success included */ + /** On every reset of the form, the one after a success included; after fetchit:reset */ reset?: (() => void) | undefined; } @@ -147,7 +147,10 @@ interface FetchItErrorDetail extends FetchItBeforeDetail { error?: unknown; } -/** fetchit:reset, not cancelable */ +/** + * fetchit:reset, not cancelable: the form is being reset (its button, + * form.reset(), or after a success); the fields still hold their values. + */ interface FetchItResetDetail { form: HTMLFormElement; fetchit: FetchItInstance;