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 @@
+
+
+
+
+
+
+
+
+
+
+
FetchIt
+
+
+
+
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.
-
-
-
-В 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 и другие.
+
+
+
+
+
+
+
+**Формы на MODX без перезагрузки страницы, с защитой от спама из коробки.**
+
+[](https://modx.com/)
+[](https://www.php.net/)
+[](#typescript)
+[](https://github.com/GulomovCreative/FetchIt/actions/workflows/ci.yml)
+[](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 бесплатно ставится через Менеджер пакетов из официального репозитория [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
+
+```
-Если на сайте 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 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;