From 01df5910f7eed5d607b7187d7e89976fd2897c73 Mon Sep 17 00:00:00 2001 From: Gulomov Bakhtovar Date: Thu, 24 Sep 2026 16:57:18 +0500 Subject: [PATCH 1/5] docs: Redraw the logo and render it with a script --- .github/logo/logo.html | 45 ++++++++++++++++++++++++++++++++++ .github/logo/logo.png | Bin 0 -> 2946 bytes .github/logo/logo@2x.png | Bin 0 -> 5767 bytes .github/logo/social.png | Bin 0 -> 17681 bytes _build/logo.mjs | 51 +++++++++++++++++++++++++++++++++++++++ package.json | 3 ++- 6 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 .github/logo/logo.html create mode 100644 .github/logo/logo.png create mode 100644 .github/logo/logo@2x.png create mode 100644 .github/logo/social.png create mode 100644 _build/logo.mjs diff --git a/.github/logo/logo.html b/.github/logo/logo.html new file mode 100644 index 0000000..39847d5 --- /dev/null +++ b/.github/logo/logo.html @@ -0,0 +1,45 @@ + + + + + + + + + + + + diff --git a/.github/logo/logo.png b/.github/logo/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..691d7c3155edd9bf7e8e2e525d56e2ca8895aaa0 GIT binary patch literal 2946 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 General > Social preview) +// +// The fonts come from Google Fonts, so it needs the network. + +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() + +async function render (scale, path, transform) { + const page = await browser.newPage({ viewport: { width: 320, height: 240 }, deviceScaleFactor: scale }) + await page.goto(url) + await page.waitForSelector('body[data-ready]') + if (transform) { + await page.evaluate(transform) + } + await page.screenshot({ path }) + await page.close() +} + +await render(1, `${dir}/logo.png`) +await render(2, `${dir}/logo@2x.png`) + +// The same logo, centred on a 1280x640 card: GitHub's size for the preview. +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' + const logo = document.querySelector('.logo') + logo.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 page.screenshot({ path: `${dir}/social.png` }) +await browser.close() +console.log(`The logo is in ${dir}/`) diff --git a/package.json b/package.json index 6b30f22..06cd4ce 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "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" From 25bdc27e6c7a9b057b12e83682371f715dfeb1df Mon Sep 17 00:00:00 2001 From: Gulomov Bakhtovar Date: Thu, 24 Sep 2026 17:01:06 +0500 Subject: [PATCH 2/5] docs: Rewrite the README around what a site needs, with the new logo --- README.md | 347 ++++++++++++++++-------- core/components/fetchit/docs/readme.txt | 5 +- 2 files changed, 233 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index 5200f7f..762d497 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,103 @@ -# FetchIt - -Компонент для MODX Revolution для отправки форм с помощью Fetch API. - -![Логотип FetchIt](https://github.com/GulomovCreative/FetchIt/blob/master/fetchit-logo.svg?raw=true&v=3) - -В CMS/CMF MODX Revolution есть компонент [FormIt](https://github.com/Sterc/FormIt): он отправляет и обрабатывает формы стандартным методом браузера, с перезагрузкой страницы. FetchIt использует FormIt (или ваш сниппет) и обрабатывает формы «на лету» через Fetch API. - -Близкий по серверной части аналог — [AjaxForm](https://github.com/modx-pro/AjaxForm). У FetchIt другие плюсы на фронте: - -## Никаких зависимостей - -FetchIt не тянет внешних JS-библиотек. У AjaxForm их три: [jQuery](https://github.com/jquery/jquery), [jquery-form](https://github.com/jquery-form/form/) и [jGrowl](https://github.com/stanlemon/jGrowl). - -Уведомления (jGrowl) можно переопределить и в AjaxForm. jQuery и jquery-form заменить сложнее. - -## Современный код - -Скрипт вместе с proof-of-work, адаптерами капч и уведомлениями весит около 30 КБ, в gzip около 9 КБ. Минифицированный `fetchit.min.js` весит около 17 КБ, в gzip около 7 КБ: его можно поставить в настройку `fetchit.frontend.js`. Других файлов скрипт не подключает. Сниппет подключает его с атрибутом `defer`, чтобы не блокировать загрузку страницы. На фронте — нативный Fetch API и `FormData` (включая файлы). - -## Удобство - -Свою вёрстку оставляете как есть: достаточно чанка формы и атрибутов `data-error`. Всплывающие сообщения и модалки подключаете через события и `FetchIt.Message`. В документации есть готовые примеры под Bootstrap, Bulma, UIKit, Notyf, SweetAlert2 и другие. +

FetchIt

+ +**Формы на MODX без перезагрузки страницы, с защитой от спама из коробки.** + +[![MODX 2.8+ | 3.x](https://img.shields.io/badge/MODX-2.8%2B%20%7C%203.x-102C53)](https://modx.com/) +[![PHP 7.4+](https://img.shields.io/badge/PHP-7.4%2B-777BB4)](https://www.php.net/) +[![TypeScript](https://img.shields.io/badge/types-TypeScript-3178C6)](#typescript) +[![CI](https://github.com/GulomovCreative/FetchIt/actions/workflows/ci.yml/badge.svg)](https://github.com/GulomovCreative/FetchIt/actions/workflows/ci.yml) +[![License: GPL-2.0](https://img.shields.io/badge/License-GPL--2.0-blue)](core/components/fetchit/docs/license.txt) + +FetchIt отправляет формы на сайте MODX через Fetch API: без перезагрузки страницы, без jQuery и других библиотек. Обрабатывает их [FormIt](https://github.com/Sterc/FormIt) со всеми его хуками и валидацией или ваш сниппет. Каждая форма сразу защищена от спама: одноразовый токен, время заполнения, поле-ловушка и лимит отправок, а при необходимости proof-of-work и капча. Один пакет для MODX 2.8 и MODX 3. + +## Содержание + +- [Возможности](#возможности) +- [Установка](#установка) +- [Быстрый старт](#быстрый-старт) +- [Параметры сниппета](#параметры-сниппета) +- [Защита от спама](#защита-от-спама) +- [Уведомления](#уведомления) +- [События и JavaScript](#события-и-javascript) +- [TypeScript](#typescript) +- [Системные настройки](#системные-настройки) +- [Переход на FetchIt 4](#переход-на-fetchit-4) +- [Требования](#требования) +- [Разработка](#разработка) +- [Лицензия](#лицензия) ## Возможности -- **FormIt из коробки.** Параметры вроде `&hooks`, `&validate`, `&emailTo` передаются в FormIt без обёрток. -- **Свой сниппет.** В `&snippet` указываете обработчик, который возвращает JSON (`success`, `message`, `data`). -- **Ошибки полей.** Элементы с `data-error="fieldName"` получают текст валидации; к полям можно повесить CSS-классы из системных настроек. -- **События.** `fetchit:before` (можно отменить отправку и дополнить `FormData`), `fetchit:after`, `fetchit:success`, `fetchit:error`, `fetchit:reset`. -- **Уведомления.** Свой `FetchIt.Message` или встроенные уведомления через настройку `fetchit.frontend.default.notifier`, без сторонних библиотек. -- **Типы для TypeScript.** Рядом со скриптом лежит `fetchit.d.ts`: `window.FetchIt`, конфигурация, `FetchIt.Message` и события с их `detail`. -- **Несколько форм на странице.** Каждый вызов сниппета получает свой ключ `data-fetchit`, каждая форма свой экземпляр обработчика. -- **Защита от спама.** Включена по умолчанию: одноразовый подписанный токен, минимальное время заполнения, скрытое поле-ловушка и лимит отправок, и при отправке через FetchIt, и без JavaScript. Свои правила добавляются плагином на событие `OnFetchItBeforeProcess`. -- **Очистка после успеха.** Параметр `&clearFieldsOnSuccess` (по умолчанию включён). -- **Fenom.** Вызов через pdoTools/`{'!FetchIt' | snippet}` поддерживается. +- **FormIt из коробки.** Параметры вроде `&hooks`, `&validate`, `&emailTo` передаются в FormIt как есть, без обёрток. Вместо FormIt можно указать свой сниппет, который возвращает JSON (`success`, `message`, `data`). +- **Своя вёрстка.** Нужны только чанк формы и атрибуты: `data-error` для ошибок полей, `data-success` и `data-validation-error` для сообщений формы. Классы для полей с ошибкой задаются в системных настройках. +- **Работает и без JavaScript.** Форма отправляется обычным способом, сообщения и введённые значения выводятся плейсхолдерами FormIt. +- **Защита от спама по умолчанию:** одноразовый подписанный токен, минимальное время заполнения, скрытое поле-ловушка и лимит отправок. По желанию proof-of-work и капча: Cloudflare Turnstile, Google reCAPTCHA v3 или Яндекс SmartCaptcha. Свои правила добавляются плагином. +- **Никаких зависимостей.** Скрипт не тянет jQuery и сторонних библиотек, использует нативные Fetch API и `FormData` (с файлами). Подключается с `defer`, других файлов не грузит. +- **Встроенные уведомления** или свои через `FetchIt.Message`: Bootstrap, SweetAlert2, что угодно. +- **События** `fetchit:before`, `fetchit:after`, `fetchit:success`, `fetchit:error`, `fetchit:reset`: дополнить данные, отменить отправку, показать модалку. +- **Типы для TypeScript** лежат рядом со скриптом. +- **Несколько форм на странице**, каждая со своим ключом и обработчиком. +- **Fenom и `@FILE`-чанки** через pdoTools на MODX 2 и MODX 3. -## Уведомления +## Установка -![Встроенные уведомления FetchIt: ошибка и успешная отправка](docs/images/notifier-desktop-ru.png) +FetchIt бесплатно ставится через Менеджер пакетов из официального репозитория [modx.com](https://modx.com/extras/package/fetchit) или из маркетплейса [modstore.pro](https://modstore.pro/packages/utilities/fetchit) ([как подключить репозиторий](https://modstore.pro/faq)). FormIt при установке ставится вместе с ним. -С настройкой `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: +Вызов сниппета там, где должна быть форма: -```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)`. - -Если на сайте Content-Security-Policy запрещает встроенные стили (`style-src` без `'unsafe-inline'`), дайте скрипту FetchIt `nonce`: стили получат тот же. Иначе FetchIt предупредит в консоли, и уведомления придётся оформить самим. - -Свой `FetchIt.Message` важнее настройки. Если в нём нет ни `success`, ни `error` (например, только спиннер в `before` и `after`), встроенные уведомления их добавят. Включить уведомления можно и из своего скрипта, с другой подписью кнопки или временем показа (`0` значит «пока не закроют»): `FetchIt.Message = FetchIt.createNotifier({ closeLabel: 'Закрыть', duration: 4000 })`. - -До FetchIt 4 настройка подключала библиотеку Notyf. Теперь её нет: стили для `.notyf__toast` и скрипты, которые вызывают `new Notyf()`, нужно поменять или подключить Notyf самим. - -## TypeScript - -Типы лежат в `assets/components/fetchit/js/fetchit.d.ts`. Скопируйте файл в проект или подключите его: +Чанк `myForm.tpl`, обычная форма с атрибутами FetchIt: + +```html +
+ + + + +
[[+fi.successMessage]]
+
[[+fi.validation_error_message]]
+
+``` -```ts -/// +- `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. Если это не важно, их можно не ставить. -// События приходят на document и не всплывают. -document.addEventListener('fetchit:error', event => { - // response равен null, если ответа FetchIt нет: сеть, чужой ответ, капча без ответа - if (event.detail.response === null) { - console.error(event.detail.error) - } -}) +FetchIt сам добавит в форму ключ `data-fetchit`, служебные поля защиты и подключит скрипт. Готовый пример есть в чанке `tpl.FetchIt.example`. -const form = document.querySelector('form') -if (form) { - FetchIt.instances.get(form)?.setError('email', 'Проверьте адрес') -} -``` +Подробная [документация](https://docs.modx.pro/components/fetchit/) с примерами: разметка, уведомления, модалки, клиентская валидация, JS API. -Скрипт проверяется по этому же файлу: и `FetchIt` с экземплярами форм, и `detail` каждого события, так что типы и код не расходятся. Уберите своё объявление `FetchIt`, если оно было (`declare var FetchIt: any`): они будут конфликтовать. Глобальный `FetchIt` появляется, когда отработал отложенный `fetchit.js`, поэтому `FetchIt.Message` задавайте из отложенного скрипта или по `DOMContentLoaded`. +## Параметры сниппета -Минимальный вызов: +| Параметр | По умолчанию | Что делает | +|---|---|---| +| `form` | `tpl.FetchIt.example` | Чанк формы. С pdoTools можно `@FILE`, `@INLINE` и Fenom | +| `snippet` | `FormIt` | Сниппет, который обрабатывает форму. Свой сниппет получает поля в `$fields` и возвращает JSON через `$FetchIt->success()` или `$FetchIt->error()` | +| `actionUrl` | `[[+assetsUrl]]action.php` | Адрес, на который отправляется форма | +| `clearFieldsOnSuccess` | `1` | Очистить поля после успешной отправки | -```modx -[[!FetchIt? - &snippet=`FormIt` - &form=`myForm.tpl` - &hooks=`email` - &emailTo=`info@domain.com` - &validate=`name:required,email:required` - &successMessage=`Сообщение отправлено` -]] -``` +Остальные параметры передаются обрабатывающему сниппету: для FormIt это `&hooks`, `&validate`, `&emailTo`, `&successMessage`, `&placeholderPrefix` и все прочие. ## Защита от спама @@ -115,7 +112,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 отправить нельзя. @@ -158,17 +155,117 @@ if (preg_match('/@(mailinator|tempmail)\./i', $email)) { Событие срабатывает и при выключенной защите. -## Ветки и версии +## Уведомления + +![Встроенные уведомления FetchIt: ошибка и успешная отправка](docs/images/notifier-desktop-ru.png) + +С настройкой `fetchit.frontend.default.notifier` ответы сервера и ошибки отправки показываются ещё и уведомлениями в углу страницы. Текст выводится как текст: теги из него убираются, HTML-сущности вроде `&` остаются как есть. -| Ветка | Пакет | MODX | -|-------|-------|------| -| `master` | 1.x | 2.x | -| `4.x` | 4.x (в разработке) | 2.x и 3.x | -| `next` | 3.x | 3.x | +- Программы экранного доступа читают уведомления из двух скрытых live-областей, которые есть на странице заранее: ошибку сразу (`role="alert"`), успех в свою очередь (`role="status"`). +- Уведомление закрывается кнопкой или само через 6 секунд. Пока на нём курсор или фокус, отсчёт стоит, а потом начинается заново. Если закрыть уведомление с клавиатуры, фокус перейдёт на соседнее уведомление или вернётся туда, откуда пришёл. +- Одновременно видно не больше трёх. Уведомление, на котором фокус, лишним не считается. -FetchIt 4 будет одним пакетом для MODX 2 и MODX 3 и заменит линии 1.x и 3.x. Он разрабатывается в ветке `4.x`; с выходом 4.0 она станет `master`, а `next` будет заморожена. +Стили подключаются первыми в ``, с селекторами из одного класса. Они сильнее общих правил сайта для элементов (`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 */ +} +``` -На [extras.modx.com](https://extras.modx.com/package/fetchit): **3.1.2-pl** (MODX 3) и **1.1.2-pl** (MODX 2). +На сайте с 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`), встроенные уведомления их добавят. Включить уведомления можно и из своего скрипта, с другой подписью кнопки или временем показа (`0` значит «пока не закроют»): `FetchIt.Message = FetchIt.createNotifier({ closeLabel: 'Закрыть', duration: 4000 })`. + +До FetchIt 4 настройка подключала библиотеку Notyf. Теперь её нет: стили для `.notyf__toast` и скрипты, которые вызывают `new Notyf()`, нужно поменять или подключить Notyf самим. + +## События и JavaScript + +События приходят на `document` и не всплывают. В `event.detail` лежат форма (`form`), её данные (`formData`), экземпляр FetchIt (`fetchit`) и ответ сервера (`response`). + +| Событие | Когда | Отмена (`event.preventDefault()`) | +|---|---|---| +| `fetchit:before` | перед отправкой, в `formData` можно дописать поля | форма не отправляется | +| `fetchit:after` | пришёл ответ FetchIt | ответ не обрабатывается: ни ошибок полей, ни сообщения, ни очистки | +| `fetchit:success` | форма принята | поля не очищаются | +| `fetchit:error` | форма отклонена или не отправилась; `response` равен `null`, если ответа FetchIt нет, причина в `error` | ошибки полей и сообщение формы не выводятся | +| `fetchit: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`. Они вызываются перед событием того же момента, так что отмена события их не отменяет: + +```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` вдвое меньше | +| `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 дней | +| `fetchit.protection.rate_limit` | `10` | Отправок одной формы с одного адреса за окно; 0 выключает | +| `fetchit.protection.rate_window` | `600` | Окно лимита, секунд | +| `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 @@ -183,6 +280,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 +290,33 @@ 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 или новее (проверяется до PHP 8.4). +- FormIt, если формы обрабатывает он; ставится вместе с FetchIt. +- pdoTools, если чанки форм на Fenom или в файлах. +- Браузеры с поддержкой ES-модулей: все актуальные. Без JavaScript формы работают как обычные. -- маркетплейса [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. ```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/: скрипт и 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 screenshots # снимки уведомлений в docs/images/, после правок их стилей +npm run logo # логотип из .github/logo/logo.html ``` -`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 +335,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,9 +346,17 @@ 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, 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 и встроенными уведомлениями. Релиз: @@ -262,3 +365,11 @@ CI проверяет каждый PR: синтаксис PHP 7.4–8.4, PHPUnit 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/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 From eb2dc79311c9e422f80984cad292b039d7a7fbdf Mon Sep 17 00:00:00 2001 From: Gulomov Bakhtovar Date: Thu, 24 Sep 2026 17:18:44 +0500 Subject: [PATCH 3/5] build: Lower the syntax to ES2019 for every browser of browserslist --- .github/workflows/ci.yml | 3 + assets/components/fetchit/js/fetchit.js | 182 ++++++++++++++------ assets/components/fetchit/js/fetchit.min.js | 4 +- package-lock.json | 14 ++ package.json | 2 + rolldown.config.ts | 4 + 6 files changed, 155 insertions(+), 54 deletions(-) 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/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/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 06cd4ce..26c5205 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "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", @@ -32,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: [ { From e84d784289800bdb9f844d2b84dd620905a3f57d Mon Sep 17 00:00:00 2001 From: Gulomov Bakhtovar Date: Thu, 24 Sep 2026 17:19:47 +0500 Subject: [PATCH 4/5] docs: Render the logo only with its fonts, add a variant for dark pages --- .github/logo/logo-dark.png | Bin 0 -> 3370 bytes .github/logo/logo-dark@2x.png | Bin 0 -> 6675 bytes .github/logo/logo.html | 15 ++++-- .github/logo/logo.png | Bin 2946 -> 3157 bytes .github/logo/logo@2x.png | Bin 5767 -> 6318 bytes _build/logo.mjs | 93 ++++++++++++++++++++++------------ 6 files changed, 71 insertions(+), 37 deletions(-) create mode 100644 .github/logo/logo-dark.png create mode 100644 .github/logo/logo-dark@2x.png diff --git a/.github/logo/logo-dark.png b/.github/logo/logo-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..4dd6fb1837b2c956202e9678b6a730441229ec66 GIT binary patch literal 3370 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 literal 0 HcmV?d00001 diff --git a/.github/logo/logo-dark@2x.png b/.github/logo/logo-dark@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..54546bd7a97803ffe6578166deb41082cdb4c0ad GIT binary patch literal 6675 zcmeI1Sy)raw#PS!2m&hFtq3C6G$PUr9zi4sNVm;QfgaArApaLyZ zB!U42B48Lq3G>jP$gBhsAcUyQ8HfxaWW@1&Aa%J-n z(G*4v`I0u)%0AWUMGLj*snB;@ zE?TFfJuECWEKBF!I|q%1b|yrB&eo!Nb*rMIVDoveD8of5vc$Gb4*f%Qf+1&Rkh^Z< zjQcQM@UVJ?J?t-&P1_bJ0jH1r>!Y{}(AQE>0d_qAlmHbcKoL+f02F|mnsW2IR89k_ zp-TF|X^X#q`};ut|3rv~`7yr)&J^z+D)ySBu{d|_uflboJ*Q&Y?afwdvhLe9(^t@m z?1^jPiu#3xT554|aU($?A<~_^bjh!(eq6O8@FI^;lrR;nnrh*pkaYvZ#S zNK<=UE(pS?w;3z!x~5p%$AsSH)16oMz>8AB9s;PBGfOA06CWdn3VMnwo<$3PQ zTIBk|;>h6ObPk{uS)S_bi+Q;+_O-=PTo*Fc>y7aUX4p6E2Ev9?HVl|0cMdjBL`nnT zykopzPMC>;1x4xOU2Sz`?BNOEU?4C%at(0C8o^H7>)qS=Xiw_1fgDSnH(_d_gQ}e$ z5a62`pX#<+EVL^Bx3U5%z4iorrtq+lkq?RHX|vd~bhNCr95(v-^TqQaAzK#wj-aH=^ zUi#iV3c*l(-XmoOSrx-XSJsvO^{F5g|Vvr`^=?6%b2o|A3+ zEC3ydJe=M##>RXvo_eLgVDUELWPt#wsp2^2+a3h*LnaNT&HSUb6kZ;*;@kp^=S|pqkpLShl+01Wa=5RU4r|UXo z8;5vHzU+&^e3K-3ot>9Y6*)vse8se}2PbbytNfbj<(_mjWU>$rajwWb0pW&dx0Y42 z;%MBn=FeHUk>`xx(UJ=XD+w*E%RBQ5W0n!pUj+CL|xpgLFqTPDU zrm3`zC&uu^ZM@lr70Pf89=C2yepFB}y)irxr7}6QsY3=E@^?A5!fs zo`ON&=0u~D22HVQJ{{}*e*R2sX zk8y*f7YibFoOI`SV-gy4-X=JtgFkO#x?(+q;LQ(=d2w2aq;Tmt1`E@bScFiEn;rDhSU z+Bi3UZ$|xzbC_&lu9}gRRkcp$3s=*Ot!4Fg@oqOiD(QC~9Wvvfm)px0)U5ufc8T;QxG>&l zW}2Gj#f`c~B-gb*|H+DTb&^l8s+^58uP{K6IMoK%U>aAj16z6F!l1Tpu6ys5NS)F+ zcL{5^(qTZs??OzSAiQY~GOk%$Kib@9e!bKZZ&p?qkG?cyc7prdATK3o#NL4Ttht(7 z`7BY-qqjKM@|<&sL&%8zapG^T)L}C=+W%&;+&0azuOh;0tr?$@82i6|cGxFv}?({CqaSI$G)P`;CV*rvqv-bM-fMnF%^P}_}o}<&2t5*^=-4CU=7L5 zXc-qX&c~))NiLD~D=AE>DrtTHIExoum04OUo-F8_@$Am$g#3if9}|o3kxXHU5_e&k zaiYUXy8_w9`}xZkldpYw>!{t@q};pi*Z7#a5)u_t@PsYx?@|NNXW*0yvh0c9X1CP? z>2QfdfUw5OiP15dae~*(-_ep3QxcTxz5XO#i7|B0s;pqh~y_ z-!ct5&XA29oUpn4+|iA5b&hNFP2U`9~qw!v?SP|%Da2co8+G_1L(*^qG#vJ$i`WyLeU z@8+6g!bA--z2vi32~QKvd2o+yn-K@gb@vy_uizR6{&zbrOsQ0LJljWY#6(?MH$apk>^^hLQ4TfdjHOSm#{NToDPQvYV;`Mu z|A$Kr~pb+g|gbOpI*$J@=e*1$b`9p(eJ&iy<#7 zX>}29DHxyWn9-RM1W7J-qD7LY=(ctrFmWKaKySX@IP}YO?PWv4KErQrP_IMRAu+=+ z;l}}5(J9a#x}Hm9ErwQ(C*DHRy0BDOf4ZQoOLcz+F7RsdTsn96wLf*TtfU8hX1V-P z&=F%vplW}r>ZHdtKBf8<+DE#biPU#vV zS6wI4SI@SetiO#f%RI0{t`1~1nO}8LUpS7*2{%tG>02Dl&q}DFgz>(3gHpa`$By)z zX?Gx|B`p5Y^acan|C0Qs{9Czch!*H9pFD@eMDB|QZM?TMax)f`ymGS16QYOiD19te zPdKb{22eckll9_j^G%(XAE%q%B)jTFLEXZsIkS6HO^9$?Ned=UdJ*J0SGadnd;D(F zd)nnRs(jAV{e=^{im@c58z_@0++jE7IedBiPUHSVItRa4Y=7`=)kOqGMtdgR99osm zir*W5@G~vDrhZy~zk<@qGetj4=YWf|TdI&dKOu5#O+1MC@ezt|vjL@(0ew(UBX9w3 zLY5HGdsaI2-rdLEKq0K}JBv=o*rR-nl#+m^mRxZTYzv|p1|3GiL7B0}{GE(NL%Xuv zQP@s=c52-ZKw71&g4EmN_5hzid+A3<-_bR1lvddgj!#PT$Vko|0~>jocsaWIa4{ce z!dmX#ZQT`{%nzMrCxZrC9g4)45Xh>D@7Ruetv9;aZ{6gYOlz#@5ziadBA_Og3>SQx?_3jnIE-&jD*H3re@Ysl|kUV^?CQv|o9dY;iNNixVO&RrDZ z7ssNvrO)IM=92!(A|*2Olc?;^?SZlVSl!kanY--aeDC%9Ja$%jm@ij^C6&&YgTYVU z+r0=Im@g5^C1VHpczUhei!X}jlIYv-$NCUF7Wd*vrD0yqyJ{*ErRgYZEIW{$!u;e7 zzQ-@&#IK=u)PE($-IwUFMo_+7@0X1M!_}*NBdebn4TZRoL9;x95%@ti7r92AFFX^V zxGQuk%?PC@Pm;D#=URSo(LIYy$$0uW9J`5uoWFGcwfj*4JJ_zJmMa!sLQ1#$BN1kTaVvIvwvPak zR>)n-UBQ+ct>a&7`nt&i-IjyAux&r&EtLJjJ+!E%>@IPb4(?~zf4K9gV6tJEz-kWi z9+47D{NF8C@W{60Vq~~R*9fJuu|6|*CYZ{-k-c`#Mq{{|igMj=X?}f!dFQIZ7qXRN zN22IVyZN>0xlzHoa5;S+LL`y+h_FU~$$5eEd(LLa@vAr)<`1feUA^*q+o?rrHqO`J z=(viThygLsMLJiZGTO){2Oe;*v%v9CDm20#I?I9G>9`^tZjNOLCWLmV=E~Xo_9i=( z8UBzO_XF1SzF}vU%1R6KFiLE=%?1OPY+1|{I=|~!LbS;EW2OT4^j+W7fk*TZ^hvN8 zBS$28%sjr(1Z&INr~r+At(=wY&^NKMT7(Zfc$>k6lB`$cKVTRuLxMFN&rUv)Q9F4n znI6)ckjfSsb=_y_sZ@c)KZid>3w?)omLt4KtZE^Kri+Y0jgG7@Eo}~Qj-8=71o?&e zEI)Z29Vm6WcnPT?S58rkLipn+OThtd&4-9N=K(aco(u7WB-ETd_nCXlCvc5!T0nQ30_bqGi2GFDDb2pa31a#Nn8NQtn z3(cY6BTJOKe?#;zIaYohPbdp-V@T+;Z5@wy8~M_eYpR~r!h`Na_p9uY`vDg5G!cW@ zjY%Msny~JE)O)d+z*FVl5H5G~HYy9E+Xe`vg$@sc;!H2;+GgL(B%*&Em)$QrFEJe; zazfr%iQAuL?5HufMUI`F*hzJ;Z*R+8Q{xRhW;F;4^&edRt%fo`S070=gEP8rPd3F7 zWICCDiDzO%d~gFGV+W%0=zgp1ct5-siTbc!_{aGb&$2kU1T9+srfOm8-$XdMy-Uqq zyD*qqM`!T|vj;966-b-g`3R6;fKH@C@ao$dm2a=(#dmk7M*XOuVll(sdSF72!z_n3 z)nHdy&v>vjwX-%Z%yKpF@hSa{^#t7@QDThZdV9vbEFt0pX1A=FCV_4}VH zbM08S#*z(>r&-$F_9^9nUb86GVJ+1_=WOi7A30O62U-v7Tv;40lEMM4FsJ_>%FKN3 z-48YVABX}0GOKC3qtvrZQ;*4U&3||b%h1Vtcv4PO{*n_F@qCZV(XuGo>qu%iD~Bgg zrHlj<@X5kAylbx?0a}6oA~`ohmUt+CYW;vJr8ky97C5eMvyjSLSXy0JudKqz;bAC7 z+yj%dB!9(FwrZy)DQv{aI&KS);jHMA|JsTp9|2r{ M1)r(@IrQc~0p;T4T>t<8 literal 0 HcmV?d00001 diff --git a/.github/logo/logo.html b/.github/logo/logo.html index 39847d5..a59ab84 100644 --- a/.github/logo/logo.html +++ b/.github/logo/logo.html @@ -3,16 +3,18 @@ @@ -29,6 +31,9 @@
FetchIt