raise editorial quality gate and revise 2018 spring
Build and deploy / deploy (push) Successful in 15s

This commit is contained in:
2026-07-31 09:20:37 +03:00
parent 44c99a2640
commit 90692f0f16
33 changed files with 4563 additions and 52 deletions
+20 -1
View File
@@ -18,17 +18,36 @@
- Добавить минимум один рисунок или диаграмму, один пример и один проверяемый источник. - Добавить минимум один рисунок или диаграмму, один пример и один проверяемый источник.
- Закончить конкретным порядком действий, ограничениями и тем, что именно следует проверить в своём проекте. - Закончить конкретным порядком действий, ограничениями и тем, что именно следует проверить в своём проекте.
## Объём и плотность
- Основной текст статьи, без HTML-разметки, заголовка, метаданных и списка источников, занимает от **5 000 до 15 000 знаков**.
- Нижняя граница — не повод искусственно растягивать выводы. Если тема проста, глубину создают контекст, контрпример, проверка и решение, а не повтор одной мысли.
- Верхняя граница — повод разбить слишком широкую тему на серию. Одна статья отвечает на один главный вопрос.
- Каждый абзац либо добавляет факт, решение, ограничение или следующий шаг. Вступления «вообще о важности темы» и эмоциональные связки без технического смысла вырезаются.
## Техническая речь
- Пишем коротко и предметно: **симптом → причина → проверка → действие**. Предпочитаем глаголы и наблюдаемые факты: «запрос вернул 403», «фильтр исключает запись», «метрика выросла на 18%».
- Один абзац — одна мысль; одно предложение не пытается одновременно описать проблему, историю команды и решение.
- Термин используется только там, где он точнее обычного слова. После первого появления даём расшифровку или пример.
- Не используем общие оценки: «в современном мире», «очень важно», «магическая сила», «просто нужно учитывать». Вместо них называем условие, риск или ограничение.
- Заголовок обещает ровно тот вопрос, на который отвечает текст. Результат не объявляется «универсальным», если он зависит от версии, нагрузки, прав или архитектуры проекта.
## Голос автора ## Голос автора
- Для 2017–2018 годов — практичная, тёплая заметка инженера: «давайте разберём», осторожные выводы, внимание к реальной ошибке и следующему шагу. - Для 2017–2018 годов — практичная, тёплая заметка инженера: «давайте разберём», осторожные выводы, внимание к реальной ошибке и следующему шагу.
- Для 2019–2021 годов — инженер развивает T-shape: от PHP и Bitrix к фронтенду, инфраструктуре и данным. Текст всё ещё говорит от первого лица, но уже связывает решение с границами системы.
- Для 2022–2024 годов — системный практик: появляются измерения, надёжность, безопасность, доставка и взаимодействие ролей. Утверждения становятся проверяемее, а выводы — спокойнее.
- Для 2025–2027 годов — наставник и техлид: автор сравнивает варианты, называет стоимость решения, объясняет компромиссы и оставляет команде воспроизводимый способ работы.
- Не подменять опыт общими фразами вроде «важно учитывать» или «магическая сила». Каждое обобщение должно опираться на случай, код, таблицу или источник. - Не подменять опыт общими фразами вроде «важно учитывать» или «магическая сила». Каждое обобщение должно опираться на случай, код, таблицу или источник.
- Не делать вид, что исторический автор уже знает инструменты и практики 2027 года. Поздние материалы могут становиться системнее, но развитие должно быть постепенным. - Не делать вид, что исторический автор уже знает инструменты и практики 2027 года. Поздние материалы могут становиться системнее, но развитие должно быть постепенным.
- Термины и сокращения раскрываются при первом появлении, если они не очевидны из контекста кода. - Термины и сокращения раскрываются при первом появлении, если они не очевидны из контекста кода.
- Полная временная карта, словарь, переходы навыков и анти-анахронизмы находятся в `editorial/voice/author-trajectory-2017-2027.md`; она обязательна для редакторского прохода.
## Тройное ревью перед публикацией ## Тройное ревью перед публикацией
1. **Факты и техника.** Сверить утверждения с источниками, проверить пример, версионные оговорки, ссылки и отсутствие ложных обещаний. 1. **Факты и техника.** Сверить утверждения с источниками, проверить пример, версионные оговорки, ссылки и отсутствие ложных обещаний.
2. **Редактура и голос.** Проверить постановку проблемы, полноту раскрытия, естественность тона соответствующего года, повторы и ясность переходов. 2. **Редактура и голос.** Проверить постановку проблемы, объём 5–15 тыс. знаков, плотность, прагматичность речи, естественность тона соответствующего года, повторы и ясность переходов.
3. **Визуал и выпуск.** Открыть изображения и диаграммы, проверить таблицы на узком экране, доступность `alt`/подписей, JSON, автоматический аудит и production-сборку. 3. **Визуал и выпуск.** Открыть изображения и диаграммы, проверить таблицы на узком экране, доступность `alt`/подписей, JSON, автоматический аудит и production-сборку.
Результат каждой ручной проверки фиксируется рядом с партией в `editorial/reviews/`. Результат каждой ручной проверки фиксируется рядом с партией в `editorial/reviews/`.
+2
View File
@@ -34,3 +34,5 @@
## Публикация ## Публикация
Первичный массовый генератор `web/scripts/publishEditorialArchive.mjs` выведен из использования: он не соответствует редакционному стандарту и не должен перезаписывать доработанные статьи. Переработка идёт небольшими тематическими тройками поверх существующего архива. Для каждой тройки есть источник текста, автоматическая проверка, ручное трёхкратное ревью и проверка сборки. Первичный массовый генератор `web/scripts/publishEditorialArchive.mjs` выведен из использования: он не соответствует редакционному стандарту и не должен перезаписывать доработанные статьи. Переработка идёт небольшими тематическими тройками поверх существующего архива. Для каждой тройки есть источник текста, автоматическая проверка, ручное трёхкратное ревью и проверка сборки.
Текущая очередь, состояние архива и входной quality gate описаны в [производственном контуре](production/README.md).
+24
View File
@@ -0,0 +1,24 @@
# Производство редакционных партий
На 31 июля 2026 года строгий аудит проходит 3 из 358 созданных материалов. Остальные 355 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия
Партия содержит три связанные, но не повторяющие друг друга статьи:
1. практический разбор с воспроизводимым решением;
2. объяснение механизма и границ ответственности;
3. полевой кейс, диагностику или сравнение вариантов.
Для каждой статьи автор готовит исследование, основной текст на 5 000–15 000 знаков, отдельный visual asset, таблицу, пример и список источников. Черновой скрипт не имеет права писать в `web/data/articles.json`: он только печатает ревизии через `--print-revisions`.
## Вход в публикацию
Основной редактор интегрирует партию только после того, как одновременно выполнены:
- исследовательское ревью: ссылки проверены, версии и ограничения названы;
- редакторское ревью: проблема в начале, нет шаблонного языка, голос соответствует году;
- визуальное ревью: рисунки открываются, таблицы работают на 375px, у рисунков есть `alt` и подписи;
- `node --check`, XML-проверка диаграмм, `npm run audit:articles -- <slugs>` и production-сборка проходят.
После этого рядом с партией появляется запись в `editorial/reviews/`, а изменение публикуется отдельным коммитом. Ни один скрипт не должен перегенерировать уже отревьюированный архив целиком.
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -22,7 +22,7 @@
- Проблема названа в первом абзаце, а финал даёт проверяемый следующий шаг. - Проблема названа в первом абзаце, а финал даёт проверяемый следующий шаг.
- На партию не найдено повторяющихся длинных предложений; исключены шаблонные формулы из первичного массового архива. - На партию не найдено повторяющихся длинных предложений; исключены шаблонные формулы из первичного массового архива.
- Тон оставлен практичным для 2018 года: есть «давайте разберём», но нет искусственной ретроспективы с инструментами и уверенностью автора 2027 года. - Тон оставлен практичным для 2018 года: есть «давайте разберём», но нет искусственной ретроспективы с инструментами и уверенностью автора 2027 года.
- Глубина после финальной правки: 5 102, 5 634 и 5 085 символов обычного текста; 9, 9 и 10 минут чтения соответственно. - Глубина после финальной правки: 5 233, 5 734 и 5 151 знак основного текста без списка источников; 9, 9 и 10 минут чтения соответственно.
## 3. Визуал и выпуск — пройдено ## 3. Визуал и выпуск — пройдено
+65
View File
@@ -0,0 +1,65 @@
# Черновое тройное ревью — февраль 2018
Партия не интегрирована в `web/data/articles.json`. Ревизии доступны только через:
```bash
node web/scripts/upgrade-2018-02.mjs --print-revisions
```
## 1. Факты и техника
| Слаг | Главный вопрос | Проверенные первичные источники | Результат |
| --- | --- | --- | --- |
| `editorial-2018-02-practice-php-diagnostics` | Как оставить диагностический факт при 500 и фатальной ошибке PHP? | [set_error_handler](https://www.php.net/manual/en/function.set-error-handler.php), [set_exception_handler](https://www.php.net/manual/en/function.set-exception-handler.php), [register_shutdown_function](https://www.php.net/manual/en/function.register-shutdown-function.php), [error_get_last](https://www.php.net/manual/en/function.error-get-last.php) | Пример не обещает перехватить ошибки до регистрации обработчиков; отдельно названы ограничения фатального пути. |
| `editorial-2018-02-mechanism-php-diagnostics` | Почему строка от `curl_exec()` не означает успех API-операции? | [curl_exec](https://www.php.net/manual/en/function.curl-exec.php), [curl_getinfo](https://www.php.net/manual/en/function.curl-getinfo.php), [curl_errno](https://www.php.net/manual/en/function.curl-errno.php), [RFC 7231](https://www.rfc-editor.org/rfc/rfc7231#section-6) | Строго разделены ошибка cURL, HTTP-статус и контракт тела; повтор записи не рекомендован без идемпотентности. |
| `editorial-2018-02-field-php-diagnostics` | Как отделить битый JSON от корректного `null`? | [json_decode](https://www.php.net/manual/en/function.json-decode.php), [json_last_error](https://www.php.net/manual/en/function.json-last-error.php), [RFC 8259](https://www.rfc-editor.org/rfc/rfc8259.html) | Используется подход PHP 7.1; отмечено, что `JSON_THROW_ON_ERROR` появился только в PHP 7.3. |
Проверено вручную:
- В каждой статье не меньше двух официальных или первичных источников.
- Утверждение о статусах HTTP ограничено протокольным уровнем; успех бизнес-операции проверяется проектным контрактом.
- В журналы не предлагается писать пароли, токены, исходное тело запроса или полный ответ партнёра.
- У примеров есть версия и границы: PHP 7.1, проектные таймауты, отсутствие универсального retry.
## 2. Редактура и голос
| Слаг | Проблема в начале | Техническая речь и тон 2018 | Объём основного текста |
| --- | --- | --- | --- |
| `editorial-2018-02-practice-php-diagnostics` | Ответ 500 без причины в журнале | Короткая практическая заметка: контекст, этап, обработчик, проверка | Проверяется скриптом, диапазон 5 000–15 000 знаков |
| `editorial-2018-02-mechanism-php-diagnostics` | Строка от cURL ошибочно объявляется успешной интеграцией | Симптом → уровень сбоя → запись в журнал → действие | Проверяется скриптом, диапазон 5 000–15 000 знаков |
| `editorial-2018-02-field-php-diagnostics` | `if (!$data)` склеивает несколько разных состояний | Один вопрос о JSON, затем конкретные значения и контракт | Проверяется скриптом, диапазон 5 000–15 000 знаков |
Проверено вручную:
- У каждой статьи один главный вопрос; темы не копируют друг друга: runtime PHP, transport/HTTP и payload JSON.
- Изъяты общие вводные о «важности» и обещания универсального решения.
- Использованы проектные оговорки вместо выдуманных цифр, версий партнёрских API или результатов замеров.
- Статьи заканчиваются действиями и ограничениями до списка источников.
## 3. Визуал и выпуск
| Материал | Назначение | Проверка |
| --- | --- | --- |
| `php-fatal-context-flow.svg` | Показывает три ветки диагностики PHP и общий контекст операции | SVG содержит `title`, `desc`, осмысленный `alt` и подпись в статье |
| `curl-outcome-classifier.svg` | Разделяет транспорт, HTTP и контракт тела | SVG содержит `title`, `desc`, осмысленный `alt` и подпись в статье |
| `json-payload-diagnostic.svg` | Разделяет ошибку декодера и нарушение JSON-контракта | SVG содержит `title`, `desc`, осмысленный `alt` и подпись в статье |
Проверки к запуску перед интеграцией:
```bash
node --check web/scripts/upgrade-2018-02.mjs
node web/scripts/upgrade-2018-02.mjs --print-revisions | jq 'length'
xmllint --noout web/public/assets/editorial/2018/php-fatal-context-flow.svg
xmllint --noout web/public/assets/editorial/2018/curl-outcome-classifier.svg
xmllint --noout web/public/assets/editorial/2018/json-payload-diagnostic.svg
```
После интеграции основной агент должен запустить общий audit-скрипт и production-сборку. Этот авторский черновик их намеренно не запускает: он не меняет архив.
### Результаты авторского прохода
- `node --check web/scripts/upgrade-2018-02.mjs` — PASS.
- `node web/scripts/upgrade-2018-02.mjs --print-revisions` вернул массив из трёх ревизий; встроенная проверка структуры прошла.
- Длина основного текста без источников: 7 109, 6 519 и 6 706 знаков соответственно; все значения в диапазоне 5 000–15 000.
- `xmllint --noout` для трёх SVG — PASS.
- Локальная браузерная отрисовка SVG заблокирована политикой среды, поэтому финальный просмотр на desktop и узком экране остаётся выпускной проверкой после интеграции в страницу. Структурная проверка выполнена: у каждого SVG единый `viewBox 1200×620`, описание `title`/`desc` и все координаты элементов лежат внутри полотна.
+65
View File
@@ -0,0 +1,65 @@
# Март 2018 — безопасная загрузка файлов: draft-review
Статус: принято в публикационный слой 31 июля 2026 после независимого audit. Эта партия существует как три ревизии из `web/scripts/upgrade-2018-03.mjs --print-revisions`; слой `web/data/editorial-revisions.mjs` сопоставляет их только по стабильным slug, не меняя даты, автора или историю Git.
| Slug | Главный вопрос | Основной текст без источников |
| --- | --- | ---: |
| `editorial-2018-03-practice-safe-uploads` | Как принять JPEG или PNG для аватара без доверия к имени и MIME-типу формы? | 5 666 знаков |
| `editorial-2018-03-mechanism-safe-uploads` | Какие признаки файла можно использовать для какой проверки? | 6 209 знаков |
| `editorial-2018-03-field-safe-uploads` | Как выдать владельцу приватный PDF, если файл хранится вне веб-корня? | 5 688 знаков |
## 1. Факты и техника — пройдено
### Практика: приём аватара
- Проверены [коды ошибок загрузки PHP](https://www.php.net/manual/en/features.file-upload.errors.php), [move_uploaded_file](https://www.php.net/manual/en/function.move-uploaded-file.php), [finfo_file](https://www.php.net/manual/en/function.finfo-file.php), [ограничение getimagesize как валидатора](https://www.php.net/manual/en/function.getimagesize.php) и [OWASP File Upload Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html).
- Пример проверяет `UPLOAD_ERR_OK`, прикладной лимит, MIME-тип через Fileinfo и размеры изображения до переноса. `getimagesize()` используется только для размеров, не как доказательство корректности изображения.
- Ограничения названы прямо: нет антивирусной проверки, CSRF-защиты и обработки миниатюр.
### Механизм: границы доверия
- Проверены [RFC 7578 для multipart/form-data](https://www.rfc-editor.org/rfc/rfc7578), [коды ошибок PHP](https://www.php.net/manual/en/features.file-upload.errors.php), [Fileinfo](https://www.php.net/manual/en/function.finfo-file.php), [getimagesize](https://www.php.net/manual/en/function.getimagesize.php) и [OWASP](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html).
- Воспроизводимый `curl`-пример не утверждает конкретный результат базы magic: он показывает разницу между заявленным клиентом `type` и типом, который определяет Fileinfo.
- Статья не называет Fileinfo антивирусом и не переносит ответственность за лимит всего запроса на одну PHP-функцию.
### Поле: выдача приватного PDF
- Проверены [OWASP для размещения файлов вне webroot](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html), [header()](https://www.php.net/manual/en/function.header.php), [readfile()](https://www.php.net/manual/en/function.readfile.php) и [RFC 6266 для Content-Disposition](https://www.rfc-editor.org/rfc/rfc6266).
- В коде ID документа и текущий пользователь участвуют в одном SQL-запросе; путь строится только из ключа, прошедшего контрактное регулярное выражение.
- Ограничения не скрыты: в примере нет Range, кеширования, ограничения частоты и эффективной выдачи больших файлов.
Версионная оговорка: код ориентирован на PHP 7.2. В статьях не используются приёмы, добавленные позднее; актуальные страницы PHP Manual взяты как первичный справочник функций и их ограничений.
## 2. Редактура и голос — пройдено после исправления стоп-условия
- Каждая статья отвечает на один вопрос и начинает с наблюдаемой ситуации, а не с общего рассуждения о безопасности.
- Для `mechanism` и `field` первоначальный строгий audit обнаружил недостаточно явную постановку проблемы. В первые два предложения добавлены формулировки `Симптом:` и `Цена ошибки`; сильная исходная подводка сохранена дальше в том же абзаце.
- Повторный CLI-audit подтвердил: обе статьи содержат симптом и цену ошибки в первых 420 знаках; длины — 6 209 и 5 688 знаков соответственно.
- Речь соответствует 2018 году: короткие технические абзацы, «давайте» и «я бы» только там, где автор делает практический вывод; нет обещаний универсального решения, поздних инструментов и шаблонных оборотов.
- Во всех трёх ревизиях есть проблема, таблица, воспроизводимый пример, порядок действий, ограничения, один рисунок и минимум четыре первичных или нормативных источника.
## 3. Визуал и выпуск — пройдено для черновика
- `xmllint --noout` прошёл для трёх SVG: `php-upload-avatar-contract.svg`, `php-upload-trust-signals.svg`, `php-private-download-flow.svg`.
- В локальном рендере SVG проверены title, доступное описание, границы текста и масштаб 1280×720: 31, 26 и 20 текстовых узлов соответственно; выходов за границы нет.
- На первой схеме во время визуальной проверки найден и исправлен контраст номеров этапов: цвет изменён с белого на тёмный `rgb(46, 82, 103)`.
- Каждый рисунок будет иметь осмысленный `alt` и подпись через данные ревизии. Таблицы обёрнуты в `table-scroll`; текущие стили блога добавляют горизонтальную прокрутку при минимальной ширине таблицы 620px.
- Production-сборка и проверка опубликованных URL не запускались намеренно: статьи ещё не интегрированы в `web/data/articles.json`. Это выпускной шаг основного агента, а не основание менять архив из этой ветки.
## Повторённые команды
```sh
node --check web/scripts/upgrade-2018-03.mjs
node web/scripts/upgrade-2018-03.mjs --print-revisions
xmllint --noout \
web/public/assets/editorial/2018/php-upload-avatar-contract.svg \
web/public/assets/editorial/2018/php-upload-trust-signals.svg \
web/public/assets/editorial/2018/php-private-download-flow.svg
```
Результат: три ревизии готовы для точечной интеграции без перезаписи остальных статей.
## Приёмка основного агента
- Повторно пройден строгий `audit-quality-batch.mjs`: 5 666 / 6 209 / 5 688 знаков основного текста; в каждой статье найдены рисунок с `alt`, таблица, код, порядок действий и отдельный раздел источников.
- Проверены безопасный import модуля и CLI-вывод ровно трёх ревизий. Production build после интеграции прошёл и сгенерировал 374 статические страницы.
+34
View File
@@ -0,0 +1,34 @@
# Апрель 2018 — черновики о символьных кодах и ЧПУ
Статус: готово к интеграции основным агентом. Этот черновик не изменяет web/data/articles.json.
| Слаг | Главный вопрос | Основной текст |
| --- | --- | ---: |
| editorial-2018-04-practice-bitrix-slugs | Как получить читаемый CODE и не принять совпадение за успех? | 6 049 знаков |
| editorial-2018-04-mechanism-bitrix-slugs | Что должно совпасть, чтобы адрес стал ELEMENT_CODE? | 6 354 знака |
| editorial-2018-04-field-bitrix-slugs | Как доказать конфликт CODE или широкий фильтр до изменения данных? | 7 111 знаков |
## Ревью 1. Факты и техника — пройдено
- Практическая статья опирается на [CUtil::translit](https://dev.1c-bitrix.ru/api_help/main/reference/cutil/translit.php), [CIBlockElement::GetList](https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/getlist.php?print=Y) и [CIBlockElement::Add](https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/add.php?print=Y). Сверены параметры транслитерации, фильтры выборки, поле CODE, возврат ID и LAST_ERROR.
- Статья о механизме ЧПУ опирается на [CComponentEngine::ParseComponentPath](https://dev.1c-bitrix.ru/api_help/main/reference/ccomponentengine/parsecomponentpath.php), [CComponentEngine::MakePathFromTemplate](https://dev.1c-bitrix.ru/api_help/main/reference/ccomponentengine/makepathfromtemplate.php) и [CIBlockElement::GetList](https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/getlist.php?print=Y). В тексте не приписывается Bitrix автоматическая уникальность URL: маршрут, переменные и выборка показаны как отдельные уровни.
- Диагностическая статья опирается на [CIBlockElement::GetList](https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/getlist.php?print=Y), [CComponentEngine::ParseComponentPath](https://dev.1c-bitrix.ru/api_help/main/reference/ccomponentengine/parsecomponentpath.php) и [CIBlockElement::Update](https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/update.php?print=Y). Сортировка в примере названа средством повторяемого вывода, а не способом выбрать правильный товар.
- Версионная граница названа явно: использованы старые API, документация которых указывает доступность методов до 2018 года. Конкретные шаблоны компонента, инфоблок и параллельный импорт оставлены проектными условиями.
- Проверены отрицательные сценарии: пустой код, два совпадения, отсутствие совпадений, нераспознанный URL и ошибка обновления.
## Ревью 2. Редактура и голос 2018 года — пройдено
- В первом абзаце каждой статьи поставлены наблюдаемый симптом и один вопрос. Три текста не повторяют друг друга: первый о построении кода, второй о превращении пути в переменную, третий о диагностике неверной карточки.
- Основной текст укладывается в требуемые 5 000–15 000 знаков. Объём набран контрактом данных, воспроизводимыми PHP-примерами, таблицами, контрпримерами и ограничениями, а не повтором вывода.
- Речь намеренно короткая и прикладная: «проверяем», «сверяем», «сохраняем», «не меняем до доказательства». Нет лозунгов, обещаний универсального решения и поздних для автора 2018 года практик.
- Тон оставлен близким ранним заметкам автора: спокойное «давайте разберём», конкретный код Bitrix и оговорка там, где правило зависит от каталога.
- После вычитки удалены общие фразы о важности темы. Термины CODE, ЧПУ, ELEMENT_CODE и GetList раскрываются в контексте первого использования.
## Ревью 3. Визуал и выпуск — пройдено для черновика
- Добавлены и XML-проверены три самостоятельные SVG-схемы: bitrix-slug-build-2018.svg, bitrix-slug-route-2018.svg и bitrix-slug-conflict-2018.svg.
- Все SVG прошли xmllint --noout. Схемы отрендерены в PNG и просмотрены вручную в масштабе 1600 px. Во время просмотра исправлены обрезанная подпись фильтра в первой схеме и пересечение нижней ветки с подписью в третьей.
- В каждом черновике есть figure с alt и подписью, таблица внутри table-scroll, не менее одного блока кода и нумерованная последовательность действий.
- node --check web/scripts/upgrade-2018-04.mjs завершился без ошибок. node web/scripts/upgrade-2018-04.mjs --check подтвердил объём, три источника, таблицу, фигуру и код для каждой ревизии.
- node web/scripts/upgrade-2018-04.mjs --print-revisions выдаёт валидный JSON-массив ровно из трёх ревизий. В каждом тексте ровно один обязательный заголовок Проверяемые источники.
- Окончательную проверку мобильной вёрстки и production-сборку выполняет основной агент после интеграции в архив.
+62
View File
@@ -0,0 +1,62 @@
# Черновое ревью — май 2018: legacy jQuery
Статус: принято в публикационный слой 31 июля 2026 после независимого audit. Партия намеренно не перезаписывает `web/data/articles.json`: `web/data/editorial-revisions.mjs` накладывает ревизии только по стабильным slug.
## Область партии
| Slug | Главный вопрос | Основной текст, знаков |
| --- | --- | ---: |
| editorial-2018-05-practice-legacy-jquery | Как повторно вызвать mount и оставить один обработчик? | 7 718 |
| editorial-2018-05-mechanism-legacy-jquery | Почему прямой click пропадает после .html()? | 8 088 |
| editorial-2018-05-field-legacy-jquery | Как держать один активный Ajax-запрос формы? | 8 729 |
Размеры посчитаны без списка источников и HTML-разметки. Каждый текст отвечает на один вопрос, начинает с наблюдаемого сбоя и заканчивается последовательностью действий с ограничениями.
## Исследование
Все ссылки — первичная официальная документация jQuery; материалы сверены 31 июля 2026 года.
| Статья | Источники |
| --- | --- |
| Повторный mount | [.on()](https://api.jquery.com/on/), [.off()](https://api.jquery.com/off/) |
| Замена DOM | [.html()](https://api.jquery.com/html/), [.on()](https://api.jquery.com/on/), [.off()](https://api.jquery.com/off/), [.data()](https://api.jquery.com/data/) |
| Ajax-форма | [jQuery.ajax()](https://api.jquery.com/jQuery.ajax/), [.serialize()](https://api.jquery.com/serialize/), [.prop()](https://api.jquery.com/prop/), [.data()](https://api.jquery.com/data/), [.removeData()](https://api.jquery.com/removeData/), [deferred.always()](https://api.jquery.com/deferred.always/) |
## Ревью 1 — факты и техника
**Пройдено.**
- Для mount проверены: пространства имён событий, снятие обработчиков по namespace, различие прямой и делегированной привязки, а также версия .on()/.off() — jQuery 1.7+.
- Для .html() проверено основное утверждение: jQuery удаляет данные и события дочерних узлов до замены содержимого. Ограничения делегирования для SVG и не всплывающих событий названы явно. Риск вставки непроверенной HTML-строки не выдан за проблему конкретного API — это предупреждение документации.
- Для Ajax-формы проверены: состав .serialize(), динамическое свойство disabled через .prop(), хранение и удаление маркера через .data()/.removeData(), роли done, fail и always у jqXHR. always используется только для освобождения UI и не анализирует разнородные аргументы resolve/reject.
- Серверные последствия не выдуманы: клиентский замок ограничен текущим DOM-экземпляром; при timeout текст не обещает, что операция не была выполнена.
## Ревью 2 — редактура и голос
**Пройдено.**
- Речь короткая и техническая: симптом → причина → проверка → действие. Нет обещаний «универсального» решения и абстрактных вступлений о важности темы.
- Голос соответствует 2018 году: код на jQuery, IIFE, var, $.ajax; без искусственного переноса поздних фреймворков или роли техлида в раннюю заметку.
- У каждой статьи свой сценарий, таблица, воспроизводимый код и порядок внедрения. Три текста не повторяют один и тот же вывод под разными заголовками.
- Убрано неподтверждённое обобщение о распространённости конкретной версии jQuery в 2018 году; примеры лишь задают поддерживаемую версию.
## Ревью 3 — визуал и выпуск
**Пройдено для черновой партии; после интеграции нужен штатный общий выпускной прогон.**
- Три SVG валидированы командой xmllint --noout и открыты в локальном рендере. После финальной правки нет обрезанных заголовков, стрелок или кодовых строк.
- У каждого будущего contentHtml есть один figure с осмысленными alt и подписью, одна таблица в обёртке div.table-scroll и три блока кода.
- В существующих стилях .table-scroll имеет горизонтальную прокрутку, а таблица — min-width: 620px; это сохраняет читаемость на узком экране без сжатия ячеек.
- Команда node --check web/scripts/upgrade-2018-05.mjs прошла. Команда node web/scripts/upgrade-2018-05.mjs --print-revisions печатает валидный JSON ровно трёх ревизий и не записывает архив.
## Перед интеграцией
1. Основной агент импортирует JSON из параметра --print-revisions и сопоставляет только три перечисленных slug.
2. Запускаются общий аудит партии и production-сборка.
3. На собранных страницах повторяется проверка таблиц в узком viewport, потому что эта ветка по задаче не изменяет articles.json.
## Приёмка основного агента
- Повторный quality-gate прошли все три статьи: 7 718 / 8 088 / 8 729 знаков основного текста, по одной схеме, таблице и трём блокам кода.
- Независимо проверены первые абзацы, источник jQuery API для делегирования и namespace событий, а также граница клиентского замка Ajax-формы. Валидное `typeof value === 'undefined'` внутри примера кода не считается артефактом генерации.
- Модуль экспортирует ровно три revision без побочного вывода при import; production build после интеграции прошёл.
+58
View File
@@ -0,0 +1,58 @@
# [Период] — ручное редакционное ревью
Партия:
- `[slug 1]`
- `[slug 2]`
- `[slug 3]`
Дата проверки: YYYY-MM-DD. Редактор: [имя или роль].
## 1. Факты и техника
Для каждой статьи:
| Статья | Главный тезис | Первичные источники | Версия / ограничение | Пример проверен |
| --- | --- | --- | --- | --- |
| `[slug]` | | | | да / нет |
- [ ] Нет непроверенных цифр, фальшивого опыта и универсальных обещаний.
- [ ] Код, команда, SQL или конфигурация соответствуют описанному сценарию.
- [ ] Известные ограничения стоят рядом с решением, а не спрятаны в финале.
Вердикт: пройти / вернуть в доработку. Причины:
## 2. Редактура, голос и объём
Для каждой статьи:
| Статья | Знаки основного текста | Симптом в начале | Период голоса | Новое умение автора | Шаблонные фразы удалены |
| --- | ---: | --- | --- | --- | --- |
| `[slug]` | | да / нет | | | да / нет |
- [ ] Основной текст — от 5 000 до 15 000 знаков без источников.
- [ ] Речь следует схеме «симптом → причина → проверка → действие».
- [ ] Каждый абзац добавляет технический факт, решение, ограничение или следующий шаг.
- [ ] Нет анахронизмов относительно `editorial/voice/author-trajectory-2017-2027.md`.
Вердикт: пройти / вернуть в доработку. Причины:
## 3. Визуал и выпуск
Для каждой статьи:
| Статья | Visual asset и назначение | `alt` и подпись | Таблица | Мобильная проверка | Сборка |
| --- | --- | --- | --- | --- | --- |
| `[slug]` | | да / нет | да / нет | 375px: да / нет | да / нет |
- [ ] Схема, иллюстрация или график объясняет часть материала, а не заполняет место.
- [ ] Таблица остаётся читаемой или прокручивается внутри контейнера на узком экране.
- [ ] Запущены `node --check`, XML-проверка SVG (если есть), `npm run audit:articles -- <slugs>` и `npm run build`.
Вердикт: пройти / вернуть в доработку. Причины:
## Итог
Статус: готово к интеграции / вернуть автору.
Изменения после ревью:
@@ -0,0 +1,279 @@
# Траектория голоса автора: 2017–2027
Это редакторская карта для продолжения архива. Она описывает не идеального
«технического автора вообще», а наблюдаемую эволюцию DarkRiDDeR: от
практика, который делится найденным решением, к инженеру, способному объяснить
границы системы и выбор команды.
Основание карты — 13 исходных публикаций 2017–2019 годов: рецепты по
Bitrix/PHP и Windows, заметки о D, а также материалы о Webpack и jQuery.
Интервью, переводы и пересказы конференционных докладов важны для тематического
круга автора, но не являются чистым образцом его фразировки. Голос автора в них
лучше искать в заголовке, подводке, выборе примера, пояснениях и практическом
выводе.
## 1. Исходный язык 2017 года
Автор начинает с предмета, а не с рассуждения о его важности. Заголовок обычно
называет стек и операцию: «Bitrix API. Функция для генерации кода элемента…»,
«Ошибка PHP. SSL certificate error…», «Компиляция 64-x разрядных программ…».
Первый абзац быстро даёт знакомую ситуацию: «Часто в Bitrix необходимо…»,
«Недавно столкнулся с такой проблемой…», «При выполнении… может возникнуть
ошибка».
Базовая интонация — доброжелательный коллега рядом с рабочим столом. Он не
строит безличную лекцию, а ведёт читателя по найденному пути: «Для начала
нужно…», «Давайте…», «Создадим…», «Открываем командную строку, пишем…».
После инструкции автор обычно называет ожидаемый результат: «После чего ошибка
должна быть решена», «Если всё прошло удачно…», «В итоге у нас получается…».
Финал осторожный и человеческий: «Возможно, в вашем случае…», «Надеюсь, что
помог», «Поздравляю».
| Наблюдаемый паттерн | Зачем он нужен | Редакторская форма |
| --- | --- | --- |
| Стек + конкретная операция в заголовке | Сразу ограничивает задачу | <code>Bitrix API. Проверка символьного кода перед сохранением</code> |
| Симптом до рецепта | Читатель узнаёт свой случай | <code>Форма возвращает ID, но изображение не привязывается к товару.</code> |
| Последовательность «для начала → действие → результат» | Делает текст выполнимым | Один шаг, команда или фрагмент кода, затем ожидаемый эффект |
| Термин и расшифровка в скобках | Автор не предполагает лишнего опыта | <code>entry-файл (точка входа сборки)</code> при первом упоминании |
| Осторожный вывод | Не выдаёт локальную находку за закон | <code>Этот путь подходит, если проблема находится именно в…</code> |
Синтаксис исходного автора не академический. В нём есть длинные объяснения,
скобки с расшифровками, разговорные переходы и иногда шероховатости. Их не надо
копировать: орфографическая ошибка, калька, устаревшее техническое утверждение
или лишняя эмоциональность не являются частью голоса. Сохраняется другое:
близость к реальному действию, прямой порядок шагов и понятный критерий
готовности.
Постоянная формула автора на всём промежутке:
> симптом → граница проблемы → проверка → действие → ожидаемый результат → ограничение
В 2017 году граница чаще всего локальна: конкретный API-вызов, настройка
Windows, браузер, файл конфигурации или форма Bitrix. Позже формула остаётся,
но граница постепенно расширяется до модуля, сервиса, потока данных и решения
команды.
## 2. Развитие по периодам
### 2017–2018: практик интеграций и среды разработки
**T-shape.** Вертикаль — PHP/Bitrix: инфоблоки, свойства, файлы, торговые
предложения, ошибки интеграции. Ширина — D, Windows-инструменты, браузер,
базовый JavaScript и первая фронтенд-сборка. Автор уверенно показывает
выполнимый фрагмент, но ещё не обобщает его до архитектурного правила.
**Допустимый словарь.** <code>инфоблок</code>, <code>торговое предложение</code>,
<code>символьный код</code>, <code>cURL</code>, <code>CA bundle</code>,
<code>timeout</code>, <code>SDK</code>, <code>linker</code>, <code>entry</code>,
<code>bundle</code>, <code>jQuery</code>, «глобальная переменная». Английский
термин поясняется, если он не виден из кода. Слова «контракт», «граница
ответственности» и «жизненный цикл» возможны, когда они привязаны к конкретным
полям или вызовам, а не заменяют объяснение.
**Синтаксис и тон.** Короткий симптом, затем нумерованный маршрут или код.
Допустимы «давайте», «проверим», «в моём случае», но без заигрывания с
читателем. Сначала действие, затем обоснование. Одно предложение не должно
одновременно объяснять API, историю платформы и решение.
**Виды доказательств.** Воспроизводимый фрагмент кода, текст ошибки, снимок
экрана, команда, файл конфигурации, ручная проверка результата, ссылка на
документацию API. Достаточно локального случая, если автор явно называет его
границы.
**Чего автор ещё не знает.** Он не пишет от лица человека, который строил
SLO, проводил разборы крупных инцидентов, внедрял распределённую трассировку,
проектировал организационные процессы или владеет экономикой платформы. Нельзя
ретроспективно добавлять ему зрелую практику threat modeling, Kubernetes,
feature flags и продуктовые метрики без отдельного, правдоподобного мостика.
### 2019–2021: инженер на стыке фронтенда, доставки и данных
**T-shape.** PHP/интеграции остаются вертикалью, но к ним добавляются
модульный JavaScript, Webpack, HTTP, контейнеризация, SQL и первые
воспроизводимые сценарии доставки. Автор уже видит, что ошибка рождается на
границе модулей, конфигураций и окружений.
**Допустимый словарь.** <code>ES-модуль</code>, <code>dependency graph</code>,
<code>source map</code>, «кеш-заголовок», <code>Dockerfile</code>, «образ»,
«миграция», «индекс», <code>EXPLAIN</code>, <code>pipeline</code>,
<code>rollback</code>. Термины <code>CI/CD</code> и «наблюдаемость» допустимы,
но каждый раз должны быть разложены на конкретный запуск, лог, метрику или
проверку.
**Синтаксис и тон.** Появляется спокойное разделение условий: «если плагин
читает <code>window.jQuery</code>…», «если контейнер стартует от
непривилегированного пользователя…». Автор всё ещё может говорить от первого
лица, но реже использует ободряющие финалы и чаще фиксирует предпосылку, версию
и побочный эффект.
**Виды доказательств.** Конфигурация до/после, размер bundle, сетевой запрос,
вывод сборки, SQL-план, контейнерный лог, тестовый запрос, документированный
rollback. Метрика допустима, когда известны источник, окно измерения и
сравниваемый вариант.
**Чего автор ещё не знает.** Нельзя изображать опыт руководителя большой
платформы, владельца многооблачных расходов или человека с многолетней
практикой incident command. Сложные распределённые схемы допустимы как
изучаемый предмет, но не как безапелляционный личный опыт.
### 2022–2024: системный практик
**T-shape.** Центр тяжести смещается от отдельного рецепта к надёжности
изменения: производительность, доступность, безопасность, тестирование,
релизы, данные и согласование ролей. Глубина остаётся технической — автор не
уходит в абстрактное управление.
**Допустимый словарь.** <code>p95</code>, «бюджет ошибок», <code>trace</code>,
<code>span</code>, «корреляционный идентификатор», <code>rate limit</code>,
<code>threat model</code>, <code>CSP</code>, <code>WCAG</code>, «контракт API»,
«идемпотентность», «канареечный релиз», «откат». Эти слова нельзя ставить
списком: рядом нужны единица измерения, граница ответственности или конкретный
сценарий отказа.
**Синтаксис и тон.** Автор пишет короче и точнее. Вместо «система стала
быстрее» — «p95 ответа снизился с 1,8 до 0,7 с на тестовом наборе из N
запросов». Вместо «следует учесть безопасность» — условие атаки, защитный
контроль и способ проверки. Появляются таблицы вариантов и отдельные абзацы
про цену решения.
**Виды доказательств.** Трасса, график, нагрузочный сценарий, результат
автотеста, матрица прав, модель угроз, чек-лист релиза, план отката. Личный
опыт отделяется от данных из внешней документации.
**Чего автор ещё не знает.** Он ещё не обязан писать стратегию компании,
правила закупок или универсальную организационную модель. Не стоит приписывать
ему неизмеренный опыт внедрения AI-практик на масштабе всей организации.
### 2025–2027: наставник и техлид
**T-shape.** Автор связывает глубину разработки с последствиями для команды:
границы сервисов, владение, стоимость сопровождения, надёжная доставка,
наблюдаемость и безопасное использование новых инструментов. Он объясняет не
только «как исправить», но и почему выбран именно этот компромисс.
**Допустимый словарь.** <code>ADR</code>, <code>owner</code>, <code>SLO</code>,
«стоимость владения», <code>blast radius</code>, «схема миграции», «контроль
деградации», «eval-набор», <code>human review</code>, «политика данных».
Термины из AI, платформенной инженерии и безопасности допустимы только при
технической привязке: входные данные, риск, метрика, контроль и владелец.
**Синтаксис и тон.** Тон спокойный, наставнический и лишён позы. Статья
сравнивает два-три варианта, называет цену каждого и оставляет короткий путь
внедрения. Первое лицо используется для наблюдения из практики, а не как
замена доказательству. Закрытие — не вдохновляющий манифест, а решение,
ограничение и следующий проверяемый шаг.
**Виды доказательств.** Матрица выбора, архитектурная схема, измерение до/после,
последствия инцидента без чувствительных деталей, прогон тестов, план
мониторинга и отката, ADR или иной зафиксированный контекст решения. Если
данные нельзя раскрыть, автор честно описывает метод и не придумывает цифры.
**Чего автор всё ещё не знает.** Десять лет публикаций не дают права
утверждать, что его путь универсален. Автор не делает прогнозов за всю отрасль,
не приписывает команде непроверенные результаты и не заменяет технический
анализ модными словами.
## 3. Как звучит прагматичная техническая речь
Плохая фраза обычно скрывает объект, условие или проверку. Хорошая называет их
прямо.
| Период | Плохо | Хорошо |
| --- | --- | --- |
| 2017–2018 | «Нужно грамотно создавать торговые предложения, иначе будут проблемы.» | «<code>CIBlockElement::Add</code> вернул ID, но предложение ещё не связано с товаром. После сохранения проверяем свойство связи и выборку каталога.» |
| 2019–2021 | «Webpack магически подключает jQuery во всём проекте.» | «Старый плагин читает <code>window.jQuery</code>. Сначала кладём импорт в <code>window</code>, затем подключаем плагин; одного <code>ProvidePlugin</code> для этого случая недостаточно.» |
| 2022–2024 | «Наблюдаемость помогла оптимизировать сервис.» | «Трасса показала 1,1 с ожидания в запросе к партнёру. Ограничили timeout и добавили отдельную метрику ошибок этого вызова.» |
| 2025–2027 | «Надо внедрить AI и Kubernetes по современным стандартам.» | «Для автосуммаризации используем только обезличенный вход. До релиза сравниваем ответы на фиксированном eval-наборе, а спорные случаи оставляем на human review.» |
Короткая техническая речь не означает телеграфный стиль. Контекст нужен, если
без него нельзя повторить решение. Лишним считается предложение, которое не
добавляет симптом, причину, проверку, действие, ограничение или результат.
## 4. Непрерывность эволюции
Во все годы сохраняются пять признаков автора:
1. Тема начинается с конкретной инженерной работы, а не с тренда.
2. Термин привязан к коду, конфигурации или наблюдаемому эффекту.
3. Читателю дают выполнимый следующий шаг.
4. Ограничение называют рядом с решением, а не мелким шрифтом в конце.
5. Заключение возвращает к исходному симптому и критерию проверки.
Меняется глубина этого же движения:
| Этап | Что расширяется | Как это проявляется в тексте |
| --- | --- | --- |
| 2017–2018 | Локальная операция | Код, команда, настройка, ручная проверка |
| 2019–2021 | Граница модулей и окружений | Конфигурация, порядок загрузки, сборка, данные |
| 2022–2024 | Поведение системы после изменения | Метрики, тесты, риски, релиз и откат |
| 2025–2027 | Последствия решения для команды | Варианты, стоимость, владелец, способ повторить решение |
Новая компетенция должна появляться как ответ на предыдущую проблему. Например,
после заметок о timeout естественна статья о границах ожидания между браузером,
прокси и сервисом; после сборки фронтенда — материал о размере bundle или
кешировании; после ручной диагностики — наблюдаемость и автоматическая
проверка. Ненормален скачок от рецепта Bitrix сразу к «корпоративной AI-стратегии»
без цепочки практических задач между ними.
## 5. Обязательные критерии редактора
Редактор считает текст эволюцией автора, а не внезапной статьёй автора 2027
года, только если выполнены все условия ниже.
1. **Временная честность.** Словарь и уровень уверенности соответствуют году.
В статье 2017 года нет нераскрытых <code>SLO</code>,
<code>OpenTelemetry</code>, <code>Kubernetes</code>, <code>LLM eval</code>
и других поздних рамок. В статье 2027 года они объяснены через технический
сценарий, а не используются как декорация.
2. **Преемственность темы.** Новая область вырастает из уже освоенной: CMS и
PHP → фронтенд/сборка → доставка и данные → надёжность и архитектурные
решения. Если мост не очевиден, его нужно назвать во вступлении.
3. **Соразмерное доказательство.** Ранний локальный рецепт подтверждается
кодом и ручной проверкой; поздний системный вывод — измерением, тестом,
схемой, сравнением вариантов или документом решения.
4. **Сохранённая оптика практики.** Даже поздний текст начинает с конкретного
сбоя, ограничения или вопроса реализации. Статья, начинающаяся с
«в современном мире» или с общего манифеста, не проходит.
5. **Постепенное усложнение синтаксиса.** Ранний текст ведёт читателя шагами;
поздний может сравнивать варианты, но не прячет действие за абстрактными
существительными.
6. **Ограниченная компетентность.** Автор называет неизвестное, зависимость от
версии, нагрузки, прав, данных или команды. Уверенный тон не заменяет
границы применимости.
7. **Непридуманный опыт.** Число, инцидент, команда и результат либо имеют
источник, либо описаны как учебный пример. Нельзя фабриковать
«сэкономили 40%» или «внедрили во всей компании».
8. **Практический артефакт.** Есть минимальный путь проверки: код, запрос,
конфигурация, таблица симптомов, схема, тест или измерение. Совет без
артефакта не соответствует исходной манере.
9. **Проверяемый финал.** В конце есть ожидаемый эффект и следующий шаг, а не
лозунг, рекламный призыв или универсальное обещание.
Статья не проходит редактуру, если содержит три или более признака
«внезапного 2027 года»: непояснённый современный жаргон, абстрактный
стратегический тон, метрики без метода, универсальные выводы, отсутствие
выполнимого шага или компетенции, не связанные с предыдущими периодами.
## 6. Три прохода редактора голоса
Эта карта дополняет общий стандарт качества и не заменяет техническое,
фактологическое и визуальное ревью.
1. **Временной проход.** Отметить год статьи, разрешённый словарь и одно новое
умение. Проверить, что оно следует из предыдущей траектории.
2. **Голосовой проход.** Найти симптом в начале, конкретный артефакт в середине
и проверяемый финал. Убрать кальки, общие оценки и фразы, в которых
существительные скрывают действие.
3. **Прагматический проход.** Для каждого абзаца ответить: что читатель теперь
может проверить или сделать? Если ответа нет, сократить, перенести или
заменить абзац фактом.
Короткая карточка решения для редактора:
Год и период:
Постоянные признаки голоса:
Новое умение и мост к нему:
Артефакт доказательства:
Оговорка или граница:
Анахронизмы, которые были удалены:
Вердикт: соответствует / вернуть в доработку
+9
View File
@@ -312,4 +312,13 @@ h3 {
.article-content { .article-content {
font-size: 16px; font-size: 16px;
} }
/* Документация Bitrix и имена API часто содержат длинные неразрывные токены. */
.article-hero h1,
.article-content h2,
.article-content h3,
.article-content :not(pre) > code,
.article-content a {
overflow-wrap: anywhere;
}
} }
+42 -39
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
import { revisions as march2018Revisions } from '../scripts/upgrade-2018-03.mjs';
import { revisions as may2018Revisions } from '../scripts/upgrade-2018-05.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
...march2018Revisions,
...may2018Revisions,
];
+12 -2
View File
@@ -1,11 +1,21 @@
import articles from '../data/articles.json'; import articles from '../data/articles.json';
import { editorialRevisions } from '../data/editorial-revisions.mjs';
const revisionBySlug = new Map(
editorialRevisions.map((revision) => [revision.slug, revision]),
);
const publishedArticles = articles.map((article) => ({
...article,
...revisionBySlug.get(article.slug),
}));
export function getArticles() { export function getArticles() {
return articles; return publishedArticles;
} }
export function getArticleBySlug(slug) { export function getArticleBySlug(slug) {
return articles.find((article) => article.slug === slug); return publishedArticles.find((article) => article.slug === slug);
} }
export function formatDate(date) { export function formatDate(date) {
@@ -0,0 +1,66 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 700" role="img" aria-labelledby="title desc">
<title id="title">Построение символьного кода элемента Bitrix</title>
<desc id="desc">Схема показывает путь от названия элемента через транслитерацию и проверку занятости кода к сохранению элемента или добавлению суффикса.</desc>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M0,0 L12,6 L0,12 z" fill="#264653"/>
</marker>
<style>
.bg { fill: #f7f4ec; }
.node { fill: #ffffff; stroke: #264653; stroke-width: 3; }
.accent { fill: #e9c46a; stroke: #264653; stroke-width: 3; }
.success { fill: #a8dadc; stroke: #264653; stroke-width: 3; }
.warn { fill: #f4a261; stroke: #264653; stroke-width: 3; }
.line { fill: none; stroke: #264653; stroke-width: 3; marker-end: url(#arrow); }
.dash { fill: none; stroke: #264653; stroke-width: 3; stroke-dasharray: 10 8; marker-end: url(#arrow); }
.label { font: 700 25px Arial, sans-serif; fill: #1d3557; }
.text { font: 20px Arial, sans-serif; fill: #264653; }
.code { font: 700 19px "Courier New", monospace; fill: #1d3557; }
.small { font: 17px Arial, sans-serif; fill: #457b9d; }
</style>
</defs>
<rect class="bg" width="1200" height="700" rx="28"/>
<text x="60" y="68" class="label">Символьный код — это цепочка проверок, а не только транслит</text>
<rect x="58" y="165" width="190" height="155" rx="18" class="node"/>
<text x="83" y="214" class="label">1. Имя</text>
<text x="83" y="252" class="text">«Кофе Classic</text>
<text x="83" y="280" class="text">250 г»</text>
<path d="M248 243 H337" class="line"/>
<rect x="347" y="150" width="220" height="185" rx="18" class="accent"/>
<text x="373" y="199" class="label">2. CUtil::</text>
<text x="373" y="230" class="label">translit</text>
<text x="373" y="270" class="small">нижний регистр,</text>
<text x="373" y="296" class="small">дефис вместо пробела</text>
<path d="M567 243 H658" class="line"/>
<rect x="668" y="165" width="210" height="155" rx="18" class="node"/>
<text x="695" y="214" class="label">3. Кандидат</text>
<text x="695" y="257" style="font: 700 16px &quot;Courier New&quot;, monospace; fill: #1d3557;">kofe-classic-250-g</text>
<path d="M878 243 H962" class="line"/>
<rect x="972" y="150" width="180" height="185" rx="18" class="node"/>
<text x="997" y="198" class="label">4. GetList</text>
<text x="997" y="233" class="small">IBLOCK_ID</text>
<text x="997" y="260" class="small">+ CODE</text>
<text x="997" y="291" class="small">есть запись?</text>
<path d="M1062 335 V438 H932" class="line"/>
<rect x="722" y="408" width="200" height="125" rx="18" class="success"/>
<text x="748" y="457" class="label">Свободен</text>
<text x="748" y="493" class="text">передаём в Add</text>
<path d="M972 243 H930 V580 H710" class="dash"/>
<rect x="460" y="534" width="240" height="120" rx="18" class="warn"/>
<text x="487" y="580" class="label">Занят</text>
<text x="487" y="616" class="text">добавляем -2, -3…</text>
<path d="M460 594 H345 V380 H770 V320" class="dash"/>
<text x="938" y="412" class="small">нет</text>
<text x="884" y="566" class="small">да</text>
<text x="58" y="676" class="small">Проверка по списку полезна для последовательного ввода. При параллельном импорте нужен отдельный проектный способ исключить гонку.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,54 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 700" role="img" aria-labelledby="title desc">
<title id="title">Диагностика конфликта символьного кода Bitrix</title>
<desc id="desc">Дерево проверки для ситуации, когда адрес карточки открывает не тот элемент: путь, переменные, выборка и набор совпадений.</desc>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M0,0 L12,6 L0,12 z" fill="#3d405b"/>
</marker>
<style>
.bg { fill: #fff8ed; }
.node { fill: #ffffff; stroke: #3d405b; stroke-width: 3; }
.focus { fill: #d8e7ff; stroke: #3d405b; stroke-width: 3; }
.ok { fill: #cce8d4; stroke: #3d405b; stroke-width: 3; }
.warn { fill: #f8d49c; stroke: #3d405b; stroke-width: 3; }
.bad { fill: #f6b3a8; stroke: #3d405b; stroke-width: 3; }
.line { fill: none; stroke: #3d405b; stroke-width: 3; marker-end: url(#arrow); }
.label { font: 700 25px Arial, sans-serif; fill: #3d405b; }
.text { font: 20px Arial, sans-serif; fill: #3d405b; }
.code { font: 700 18px "Courier New", monospace; fill: #3d405b; }
.small { font: 17px Arial, sans-serif; fill: #5c677d; }
</style>
</defs>
<rect class="bg" width="1200" height="700" rx="28"/>
<text x="56" y="67" class="label">Карточка открывает не тот товар: сначала считаем совпадения</text>
<rect x="462" y="108" width="278" height="112" rx="18" class="focus"/>
<text x="491" y="155" class="label">Ожидаемый URL</text>
<text x="491" y="190" class="code">.../classic-250-g/</text>
<path d="M601 220 V296" class="line"/>
<rect x="403" y="306" width="396" height="116" rx="18" class="node"/>
<text x="430" y="352" class="label">Что компонент получил?</text>
<text x="430" y="389" class="code">ELEMENT_CODE = classic-250-g</text>
<path d="M601 422 V495" class="line"/>
<rect x="360" y="425" width="480" height="92" rx="18" class="focus"/>
<text x="390" y="466" class="label">GetList по этому CODE</text>
<text x="390" y="495" class="small">в том же IBLOCK_ID и с теми же фильтрами</text>
<path d="M360 471 H288 V594 H294" class="line"/>
<rect x="56" y="544" width="228" height="98" rx="18" class="warn"/>
<text x="80" y="585" class="label">0 записей</text>
<text x="80" y="613" class="small">шаблон, фильтр, активность</text>
<path d="M840 471 H912 V594 H906" class="line"/>
<rect x="916" y="544" width="228" height="98" rx="18" class="bad"/>
<text x="940" y="585" class="label">2+ записи</text>
<text x="940" y="613" class="small">конфликт CODE или фильтр</text>
<path d="M600 517 V558" class="line"/>
<rect x="438" y="558" width="324" height="84" rx="18" class="ok"/>
<text x="469" y="610" style="font: 700 23px Arial, sans-serif; fill: #3d405b;">1 запись: сверяем ID</text>
<text x="56" y="676" class="small">Кеш проверяем после данных и маршрута. Иначе он становится удобным, но недоказанным объяснением.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -0,0 +1,64 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 700" role="img" aria-labelledby="title desc">
<title id="title">Путь URL до элемента инфоблока в Bitrix</title>
<desc id="desc">Диаграмма показывает как адрес страницы разбирается шаблоном SEF, превращается в переменные и используется для поиска элемента инфоблока.</desc>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M0,0 L12,6 L0,12 z" fill="#1d3557"/>
</marker>
<style>
.bg { fill: #f5f7fb; }
.node { fill: #ffffff; stroke: #1d3557; stroke-width: 3; }
.route { fill: #cde8f0; stroke: #1d3557; stroke-width: 3; }
.query { fill: #f7d488; stroke: #1d3557; stroke-width: 3; }
.result { fill: #b9e3c6; stroke: #1d3557; stroke-width: 3; }
.fail { fill: #f6b3a8; stroke: #1d3557; stroke-width: 3; }
.line { fill: none; stroke: #1d3557; stroke-width: 3; marker-end: url(#arrow); }
.label { font: 700 25px Arial, sans-serif; fill: #1d3557; }
.text { font: 20px Arial, sans-serif; fill: #264653; }
.code { font: 700 18px "Courier New", monospace; fill: #1d3557; }
.small { font: 17px Arial, sans-serif; fill: #457b9d; }
</style>
</defs>
<rect class="bg" width="1200" height="700" rx="28"/>
<text x="56" y="67" class="label">Адрес не ищет запись сам: компонент сначала восстанавливает переменные</text>
<rect x="56" y="160" width="238" height="154" rx="18" class="node"/>
<text x="82" y="209" class="label">Запрос браузера</text>
<text x="82" y="254" class="code">/catalog/kofe/</text>
<text x="82" y="281" class="code">classic-250-g/</text>
<path d="M294 237 H380" class="line"/>
<rect x="390" y="135" width="256" height="205" rx="18" class="route"/>
<text x="416" y="185" class="label">SEF-шаблон</text>
<text x="416" y="227" class="code">#SECTION_CODE#/</text>
<text x="416" y="255" class="code">#ELEMENT_CODE#/</text>
<text x="416" y="301" class="small">ParseComponentPath</text>
<path d="M646 237 H731" class="line"/>
<rect x="741" y="135" width="240" height="205" rx="18" class="query"/>
<text x="767" y="184" class="label">Переменные</text>
<text x="767" y="225" class="code">SECTION_CODE</text>
<text x="767" y="254" class="code">ELEMENT_CODE</text>
<text x="767" y="301" class="small">не запись в БД</text>
<path d="M981 237 H1066" class="line"/>
<rect x="1076" y="160" width="84" height="154" rx="18" class="node"/>
<text x="1093" y="209" class="label">Query</text>
<text x="1092" y="253" class="small">CODE</text>
<text x="1092" y="279" class="small">+ filter</text>
<path d="M1118 314 V431 H941" class="line"/>
<rect x="692" y="402" width="239" height="133" rx="18" class="result"/>
<text x="720" y="452" class="label">Элемент найден</text>
<text x="720" y="490" class="text">детальная страница</text>
<path d="M1076 239 H1018 V592 H839" class="line"/>
<rect x="600" y="564" width="229" height="95" rx="18" class="fail"/>
<text x="625" y="606" class="label">Нет совпадения</text>
<text x="625" y="635" class="text">404 или другой путь</text>
<text x="56" y="676" class="small">Если шаблон, имя переменной и фильтр расходятся, менять CODE бесполезно: сначала надо увидеть, какое значение восстановлено из URL.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 620" role="img" aria-labelledby="title desc">
<title id="title">Три уровня результата cURL-интеграции</title>
<desc id="desc">После curl_exec сначала проверяется false и ошибка транспорта, затем код HTTP, а затем контракт тела ответа.</desc>
<defs>
<linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1f2437"/>
<stop offset="1" stop-color="#3b315f"/>
</linearGradient>
<marker id="flow-arrow" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto">
<path d="M0 0 L10 5 L0 10z" fill="#f8e9ac"/>
</marker>
</defs>
<rect width="1200" height="620" rx="34" fill="url(#background)"/>
<text x="74" y="88" fill="#fff9e4" font-family="Arial, sans-serif" font-size="32" font-weight="700">cURL отвечает за передачу, а не за успех операции</text>
<text x="74" y="125" fill="#d1c5e6" font-family="Arial, sans-serif" font-size="19">Проверки идут слева направо: транспорт → HTTP → полезная нагрузка</text>
<g font-family="Arial, sans-serif">
<rect x="75" y="228" width="194" height="116" rx="18" fill="#f8e9ac"/>
<text x="109" y="275" fill="#352f52" font-size="22" font-weight="700">curl_exec()</text>
<text x="118" y="310" fill="#352f52" font-size="17">получить тело</text>
<path d="M269 286 H385" fill="none" stroke="#f8e9ac" stroke-width="5" marker-end="url(#flow-arrow)"/>
<rect x="405" y="228" width="200" height="116" rx="18" fill="#e4d7ff"/>
<text x="438" y="274" fill="#352f52" font-size="22" font-weight="700">body === false?</text>
<text x="450" y="310" fill="#352f52" font-size="17">cURL + сеть</text>
<path d="M505 344 V439 H325" fill="none" stroke="#f8e9ac" stroke-width="5" marker-end="url(#flow-arrow)"/>
<text x="422" y="405" fill="#f8e9ac" font-size="16">да</text>
<rect x="112" y="438" width="214" height="104" rx="18" fill="#f2b8ae"/>
<text x="145" y="481" fill="#5d2427" font-size="21" font-weight="700">transport error</text>
<text x="151" y="514" fill="#5d2427" font-size="16">errno, error, time</text>
<path d="M605 286 H695" fill="none" stroke="#f8e9ac" stroke-width="5" marker-end="url(#flow-arrow)"/>
<text x="636" y="265" fill="#f8e9ac" font-size="16">нет</text>
<rect x="715" y="228" width="182" height="116" rx="18" fill="#b8e8e0"/>
<text x="745" y="274" fill="#21423f" font-size="22" font-weight="700">HTTP 2xx?</text>
<text x="737" y="310" fill="#21423f" font-size="17">curl_getinfo</text>
<path d="M806 344 V439 H965" fill="none" stroke="#f8e9ac" stroke-width="5" marker-end="url(#flow-arrow)"/>
<text x="842" y="405" fill="#f8e9ac" font-size="16">нет</text>
<rect x="966" y="438" width="170" height="104" rx="18" fill="#f2b8ae"/>
<text x="1003" y="481" fill="#5d2427" font-size="21" font-weight="700">HTTP error</text>
<text x="992" y="514" fill="#5d2427" font-size="16">status + route</text>
<path d="M897 286 H995" fill="none" stroke="#f8e9ac" stroke-width="5" marker-end="url(#flow-arrow)"/>
<text x="927" y="265" fill="#f8e9ac" font-size="16">да</text>
<rect x="1015" y="228" width="121" height="116" rx="18" fill="#d4e8ff"/>
<text x="1045" y="274" fill="#294465" font-size="21" font-weight="700">Тело</text>
<text x="1032" y="310" fill="#294465" font-size="16">контракт</text>
</g>
<text x="75" y="585" fill="#d1c5e6" font-family="Arial, sans-serif" font-size="18">Статус 404 или 500 может прийти как строка: это не false для curl_exec().</text>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,73 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 900" role="img" aria-labelledby="title desc">
<title id="title">Контракт Ajax-формы в legacy jQuery</title>
<desc id="desc">Диаграмма показывает состояния формы: готова, запрос отправлен, успешный или ошибочный ответ, затем обязательное освобождение интерфейса в обработчике always.</desc>
<defs>
<style>
.bg { fill: #fffdfa; }
.title { fill: #1e252d; font: 700 39px Arial, sans-serif; }
.sub { fill: #64707d; font: 25px Arial, sans-serif; }
.state { stroke-width: 3; }
.idle { fill: #eef3fa; stroke: #355c9a; }
.request { fill: #fff4e8; stroke: #b4432a; }
.success { fill: #e5f2ed; stroke: #216869; }
.failure { fill: #f9eceb; stroke: #a43228; }
.final { fill: #f3f6f9; stroke: #64707d; }
.state-title { fill: #1e252d; font: 700 30px Arial, sans-serif; text-anchor: middle; }
.state-copy { fill: #44515d; font: 23px Arial, sans-serif; text-anchor: middle; }
.code { fill: #18385d; font: 700 18px "Courier New", monospace; text-anchor: middle; }
.arrow { fill: none; stroke: #64707d; stroke-width: 6; marker-end: url(#arrow); }
.safe { fill: #65492e; font: 700 26px Arial, sans-serif; text-anchor: middle; }
</style>
<marker id="arrow" markerWidth="14" markerHeight="14" refX="11" refY="7" orient="auto">
<path d="M0,0 L14,7 L0,14 z" fill="#64707d"/>
</marker>
</defs>
<rect class="bg" width="1200" height="900"/>
<text class="title" x="65" y="72">Ajax-форма: интерфейс возвращается</text>
<text class="title" x="65" y="115">в готовое состояние</text>
<text class="sub" x="65" y="158">Клиентский флаг убирает повторный submit в текущем DOM, а always освобождает кнопку.</text>
<rect class="state idle" x="75" y="235" width="235" height="150" rx="25"/>
<text class="state-title" x="192" y="286">1. Готова</text>
<text class="state-copy" x="192" y="327">Кнопка доступна,</text>
<text class="state-copy" x="192" y="358">запроса нет.</text>
<path class="arrow" d="M310 310 H410"/>
<rect class="state request" x="410" y="215" width="380" height="190" rx="25"/>
<text class="state-title" x="600" y="267">2. submit</text>
<text class="code" x="600" y="302">data('request')</text>
<text class="code" x="600" y="329">+ prop('disabled')</text>
<text class="state-copy" x="600" y="367">Один jqXHR хранится на форме.</text>
<text class="state-copy" x="600" y="393">Повторный submit выходит сразу.</text>
<path class="arrow" d="M790 310 H885"/>
<rect class="state final" x="885" y="235" width="240" height="150" rx="25"/>
<text class="state-title" x="1005" y="286">3. jqXHR</text>
<text class="state-copy" x="1005" y="327">Серверный ответ</text>
<text class="state-copy" x="1005" y="358">или ошибка сети.</text>
<path class="arrow" d="M1005 385 V500 H795"/>
<path class="arrow" d="M1005 385 V500 H405"/>
<rect class="state success" x="625" y="535" width="340" height="155" rx="25"/>
<text class="state-title" x="795" y="584">4a. done</text>
<text class="state-copy" x="795" y="625">Показать подтверждённый</text>
<text class="state-copy" x="795" y="656">результат сервера.</text>
<rect class="state failure" x="235" y="535" width="340" height="155" rx="25"/>
<text class="state-title" x="405" y="584">4b. fail</text>
<text class="state-copy" x="405" y="625">Показать ошибку и не</text>
<text class="state-copy" x="405" y="656">считать отправку успехом.</text>
<path class="arrow" d="M405 690 V758 H600"/>
<path class="arrow" d="M795 690 V758 H600"/>
<rect class="state final" x="415" y="780" width="370" height="75" rx="20"/>
<text class="code" x="600" y="816">always: removeData</text>
<text class="code" x="600" y="841">+ disabled(false)</text>
<text class="safe" x="600" y="885">Ограничение: клиент не заменяет серверную защиту от повторной операции.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,69 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 890" role="img" aria-labelledby="title desc">
<title id="title">Прямая и делегированная привязка событий после замены HTML</title>
<desc id="desc">Сравнение двух подходов: прямой обработчик находится на кнопке и исчезает при замене содержимого контейнера, делегированный обработчик остаётся на постоянном контейнере и получает события от новой кнопки.</desc>
<defs>
<style>
.bg { fill: #fffdfa; }
.title { fill: #1e252d; font: 700 40px Arial, sans-serif; }
.sub { fill: #64707d; font: 25px Arial, sans-serif; }
.panel { fill: #ffffff; stroke-width: 3; }
.bad { stroke: #b4432a; }
.good { stroke: #216869; }
.panel-title { fill: #1e252d; font: 700 31px Arial, sans-serif; text-anchor: middle; }
.copy { fill: #44515d; font: 23px Arial, sans-serif; text-anchor: middle; }
.code { fill: #18385d; font: 700 17px "Courier New", monospace; text-anchor: middle; }
.root { fill: #eef3fa; stroke: #355c9a; stroke-width: 3; }
.old { fill: #fff4e8; stroke: #b4432a; stroke-width: 3; }
.new { fill: #e5f2ed; stroke: #216869; stroke-width: 3; }
.handler { fill: #f3f6f9; stroke: #64707d; stroke-width: 2; }
.cross { stroke: #b4432a; stroke-width: 7; stroke-linecap: round; }
.arrow { fill: none; stroke: #64707d; stroke-width: 5; marker-end: url(#arrow); }
.bubble { fill: none; stroke: #216869; stroke-width: 5; stroke-dasharray: 12 10; marker-end: url(#arrow-green); }
.note { fill: #65492e; font: 700 26px Arial, sans-serif; text-anchor: middle; }
</style>
<marker id="arrow" markerWidth="14" markerHeight="14" refX="11" refY="7" orient="auto">
<path d="M0,0 L14,7 L0,14 z" fill="#64707d"/>
</marker>
<marker id="arrow-green" markerWidth="14" markerHeight="14" refX="11" refY="7" orient="auto">
<path d="M0,0 L14,7 L0,14 z" fill="#216869"/>
</marker>
</defs>
<rect class="bg" width="1200" height="890"/>
<text class="title" x="65" y="82">После container.html(): что остаётся?</text>
<text class="sub" x="65" y="126">Событие переживает замену DOM только тогда, когда привязано к постоянному предку.</text>
<rect class="panel bad" x="60" y="190" width="510" height="570" rx="28"/>
<text class="panel-title" x="315" y="245">Прямая привязка к кнопке</text>
<text class="code" x="315" y="282">$('.js-remove')</text>
<text class="code" x="315" y="310">.on('click', handler)</text>
<rect class="root" x="125" y="335" width="380" height="170" rx="20"/>
<text class="copy" x="315" y="378">#cart получает новый HTML</text>
<rect class="old" x="210" y="414" width="210" height="60" rx="12"/>
<text class="copy" x="315" y="452">старая кнопка</text>
<rect class="handler" x="160" y="530" width="310" height="70" rx="15"/>
<text class="copy" x="315" y="563">обработчик жил</text>
<text class="copy" x="315" y="589">на дочернем узле</text>
<path class="arrow" d="M315 505 V526"/>
<path class="cross" d="M256 636 L374 704 M374 636 L256 704"/>
<text class="copy" x="315" y="735">Новая кнопка создана без него.</text>
<rect class="panel good" x="630" y="190" width="510" height="570" rx="28"/>
<text class="panel-title" x="885" y="245">Делегирование от #cart</text>
<text class="code" x="885" y="282">$('#cart').on('click.cart',</text>
<text class="code" x="885" y="310">'.js-remove', handler)</text>
<rect class="root" x="695" y="335" width="380" height="235" rx="20"/>
<text class="copy" x="885" y="378">#cart остаётся в DOM</text>
<rect class="handler" x="748" y="402" width="274" height="58" rx="14"/>
<text class="copy" x="885" y="439">обработчик на контейнере</text>
<rect class="new" x="780" y="487" width="210" height="60" rx="12"/>
<text class="copy" x="885" y="525">новая кнопка</text>
<path class="bubble" d="M885 487 V463"/>
<text class="copy" x="885" y="620">Клик всплывает до #cart.</text>
<text class="copy" x="885" y="655">Перепривязывать кнопку не нужно.</text>
<rect x="160" y="800" width="880" height="54" rx="16" fill="#f3f6f9" stroke="#64707d" stroke-width="2"/>
<text class="note" x="600" y="836">Корень — ближайший постоянный контейнер.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

@@ -0,0 +1,58 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 790" role="img" aria-labelledby="title desc">
<title id="title">Повторная инициализация jQuery-виджета без дублирования обработчиков</title>
<desc id="desc">Схема показывает, как повторный вызов функции инициализации снимает только свои обработчики через пространство имён и затем назначает один новый обработчик.</desc>
<defs>
<style>
.bg { fill: #fffdfa; }
.title { fill: #1e252d; font: 700 42px Arial, sans-serif; }
.sub { fill: #64707d; font: 26px Arial, sans-serif; }
.card { stroke-width: 3; }
.warm { fill: #fff4e8; stroke: #b4432a; }
.blue { fill: #eef3fa; stroke: #355c9a; }
.green { fill: #e5f2ed; stroke: #216869; }
.card-title { fill: #1e252d; font: 700 30px Arial, sans-serif; text-anchor: middle; }
.card-copy { fill: #44515d; font: 24px Arial, sans-serif; text-anchor: middle; }
.code { fill: #18385d; font: 700 21px "Courier New", monospace; text-anchor: middle; }
.note { fill: #65492e; font: 700 28px Arial, sans-serif; text-anchor: middle; }
.small { fill: #44515d; font: 23px Arial, sans-serif; text-anchor: middle; }
.arrow { fill: none; stroke: #64707d; stroke-width: 6; marker-end: url(#arrow); }
</style>
<marker id="arrow" markerWidth="14" markerHeight="14" refX="11" refY="7" orient="auto">
<path d="M0,0 L14,7 L0,14 z" fill="#64707d"/>
</marker>
</defs>
<rect class="bg" width="1200" height="790"/>
<text class="title" x="70" y="78">Повторный mount: один обработчик</text>
<text class="title" x="70" y="126">при любом числе вызовов</text>
<text class="sub" x="70" y="170">Пространство имён отделяет события виджета от остального legacy-кода.</text>
<rect class="card warm" x="70" y="245" width="300" height="235" rx="28"/>
<text class="card-title" x="220" y="300">1. mount(root)</text>
<text class="card-copy" x="220" y="345">Виджет вызывают</text>
<text class="card-copy" x="220" y="380">после Ajax, таба</text>
<text class="card-copy" x="220" y="415">или повторного рендера.</text>
<path class="arrow" d="M370 362 H455"/>
<rect class="card blue" x="455" y="245" width="300" height="235" rx="28"/>
<text class="card-title" x="605" y="300">2. Снять только свои</text>
<text class="code" x="605" y="360">.off('.orderForm')</text>
<text class="card-copy" x="605" y="410">Соседние click-события</text>
<text class="card-copy" x="605" y="443">не трогаем.</text>
<path class="arrow" d="M755 362 H840"/>
<rect class="card green" x="840" y="245" width="290" height="235" rx="28"/>
<text class="card-title" x="985" y="300">3. Назначить один</text>
<text class="code" x="985" y="360">.on('click.orderForm')</text>
<text class="card-copy" x="985" y="410">Новый обработчик</text>
<text class="card-copy" x="985" y="443">предсказуемо один.</text>
<path class="arrow" d="M600 520 V600"/>
<rect x="180" y="620" width="840" height="90" rx="22" fill="#f3f6f9" stroke="#64707d" stroke-width="2"/>
<text class="note" x="600" y="660">Проверка: три вызова mount() → одна отправка формы.</text>
<text class="small" x="600" y="694">Это свойство функции инициализации, а не удача порядка загрузки.</text>
<text class="small" x="600" y="755">Схема для статьи о jQuery 3.x и legacy-интерфейсе, май 2018.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 620" role="img" aria-labelledby="title desc">
<title id="title">Диагностика JSON-ответа API</title>
<desc id="desc">Сырой ответ разбирается функцией json_decode, затем проверяется json_last_error, а при успехе — тип и обязательные поля контракта.</desc>
<defs>
<linearGradient id="json-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#173a46"/>
<stop offset="1" stop-color="#245f55"/>
</linearGradient>
<marker id="json-arrow" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto">
<path d="M0 0 L10 5 L0 10z" fill="#f2d38d"/>
</marker>
</defs>
<rect width="1200" height="620" rx="34" fill="url(#json-bg)"/>
<text x="72" y="86" fill="#fcf7e8" font-family="Arial, sans-serif" font-size="32" font-weight="700">«null» и ошибка синтаксиса — не один случай</text>
<text x="72" y="124" fill="#c2dfd2" font-family="Arial, sans-serif" font-size="19">Сначала проверяем корректность JSON, потом — договор конкретного endpoint</text>
<g font-family="Arial, sans-serif">
<rect x="68" y="242" width="208" height="122" rx="19" fill="#d4e8ff"/>
<text x="112" y="289" fill="#244868" font-size="23" font-weight="700">Сырой ответ</text>
<text x="102" y="323" fill="#244868" font-size="17">строка + размер</text>
<path d="M276 303 H386" fill="none" stroke="#f2d38d" stroke-width="5" marker-end="url(#json-arrow)"/>
<rect x="407" y="242" width="202" height="122" rx="19" fill="#f2d38d"/>
<text x="440" y="289" fill="#4a3b21" font-size="23" font-weight="700">json_decode</text>
<text x="446" y="323" fill="#4a3b21" font-size="17">без догадок</text>
<path d="M609 303 H716" fill="none" stroke="#f2d38d" stroke-width="5" marker-end="url(#json-arrow)"/>
<rect x="737" y="242" width="192" height="122" rx="19" fill="#b8e8e0"/>
<text x="771" y="289" fill="#204944" font-size="22" font-weight="700">json_last_error</text>
<text x="776" y="323" fill="#204944" font-size="17">JSON_ERROR_NONE?</text>
<path d="M833 364 V452 H609" fill="none" stroke="#f2d38d" stroke-width="5" marker-end="url(#json-arrow)"/>
<text x="780" y="414" fill="#f2d38d" font-size="16">нет</text>
<rect x="408" y="449" width="204" height="104" rx="19" fill="#f5c5bb"/>
<text x="438" y="491" fill="#61332d" font-size="21" font-weight="700">decode error</text>
<text x="431" y="523" fill="#61332d" font-size="16">код + хеш тела</text>
<path d="M929 303 H1000" fill="none" stroke="#f2d38d" stroke-width="5" marker-end="url(#json-arrow)"/>
<text x="948" y="281" fill="#f2d38d" font-size="16">да</text>
<rect x="1021" y="242" width="125" height="122" rx="19" fill="#e2d5ff"/>
<text x="1047" y="286" fill="#4c376c" font-size="20" font-weight="700">Контракт</text>
<text x="1036" y="320" fill="#4c376c" font-size="16">тип и поля</text>
<path d="M1083 364 V452 H1000" fill="none" stroke="#f2d38d" stroke-width="5" marker-end="url(#json-arrow)"/>
<rect x="797" y="449" width="204" height="104" rx="19" fill="#f5c5bb"/>
<text x="823" y="491" fill="#61332d" font-size="21" font-weight="700">contract error</text>
<text x="830" y="523" fill="#61332d" font-size="16">валидный JSON</text>
</g>
<text x="72" y="584" fill="#c2dfd2" font-family="Arial, sans-serif" font-size="18">Не пишем тело в общий журнал: размер и SHA-256 достаточно сравнить два ответа.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 620" role="img" aria-labelledby="title desc">
<title id="title">Диагностика PHP-ошибки в интеграции</title>
<desc id="desc">Контекст операции создаётся перед вызовом партнёра и соединяется с тремя ветками обработки: warning, непойманное исключение и фатальная ошибка при завершении PHP.</desc>
<defs>
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="#101828"/>
<stop offset="1" stop-color="#1d3d4f"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="10" stdDeviation="10" flood-color="#06111a" flood-opacity=".32"/>
</filter>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto">
<path d="M0 0 L10 5 L0 10z" fill="#b8e8e0"/>
</marker>
</defs>
<rect width="1200" height="620" rx="34" fill="url(#bg)"/>
<text x="72" y="88" fill="#eaf5f4" font-family="Arial, sans-serif" font-size="32" font-weight="700">Контекст не должен появляться после падения</text>
<text x="72" y="125" fill="#a9c8c4" font-family="Arial, sans-serif" font-size="19">request ID и этап операции создаются до внешнего вызова</text>
<g filter="url(#shadow)">
<rect x="72" y="215" width="220" height="150" rx="20" fill="#f0b667"/>
<text x="104" y="267" fill="#192538" font-family="Arial, sans-serif" font-size="22" font-weight="700">Операция</text>
<text x="104" y="302" fill="#192538" font-family="Arial, sans-serif" font-size="18">request_id</text>
<text x="104" y="329" fill="#192538" font-family="Arial, sans-serif" font-size="18">stage</text>
</g>
<path d="M292 290 H395" fill="none" stroke="#b8e8e0" stroke-width="5" marker-end="url(#arrow)"/>
<circle cx="418" cy="290" r="15" fill="#b8e8e0"/>
<path d="M433 290 H486 V193 H558" fill="none" stroke="#b8e8e0" stroke-width="5" marker-end="url(#arrow)"/>
<path d="M433 290 H486 V291 H558" fill="none" stroke="#b8e8e0" stroke-width="5" marker-end="url(#arrow)"/>
<path d="M433 290 H486 V389 H558" fill="none" stroke="#b8e8e0" stroke-width="5" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="578" y="137" width="255" height="112" rx="18" fill="#d4e8ff"/>
<text x="608" y="180" fill="#183653" font-family="Arial, sans-serif" font-size="21" font-weight="700">warning / notice</text>
<text x="608" y="214" fill="#183653" font-family="Arial, sans-serif" font-size="17">set_error_handler</text>
<rect x="578" y="235" width="255" height="112" rx="18" fill="#d8f1e5"/>
<text x="608" y="278" fill="#174133" font-family="Arial, sans-serif" font-size="21" font-weight="700">Throwable</text>
<text x="608" y="312" fill="#174133" font-family="Arial, sans-serif" font-size="17">set_exception_handler</text>
<rect x="578" y="333" width="255" height="112" rx="18" fill="#f6d9d2"/>
<text x="608" y="376" fill="#63342b" font-family="Arial, sans-serif" font-size="21" font-weight="700">fatal error</text>
<text x="608" y="410" fill="#63342b" font-family="Arial, sans-serif" font-size="17">shutdown + error_get_last</text>
</g>
<path d="M833 193 H930 V291 H994" fill="none" stroke="#b8e8e0" stroke-width="5" marker-end="url(#arrow)"/>
<path d="M833 291 H994" fill="none" stroke="#b8e8e0" stroke-width="5" marker-end="url(#arrow)"/>
<path d="M833 389 H930 V291 H994" fill="none" stroke="#b8e8e0" stroke-width="5" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="1014" y="215" width="140" height="150" rx="20" fill="#b8e8e0"/>
<text x="1044" y="273" fill="#183b3a" font-family="Arial, sans-serif" font-size="22" font-weight="700">Лог</text>
<text x="1037" y="307" fill="#183b3a" font-family="Arial, sans-serif" font-size="16">один факт</text>
<text x="1034" y="333" fill="#183b3a" font-family="Arial, sans-serif" font-size="16">для разбора</text>
</g>
<text x="72" y="531" fill="#a9c8c4" font-family="Arial, sans-serif" font-size="18">Не пишем: пароли, токены, полный запрос и полный ответ партнёра.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,76 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" role="img" aria-labelledby="title desc">
<title id="title">Выдача приватного документа через PHP</title>
<desc id="desc">Последовательность запроса: браузер, маршрут приложения, база документов, закрытое хранилище, HTTP-ответ.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#f6f8fc"/>
<stop offset="1" stop-color="#edf5f1"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#123049" flood-opacity=".11"/>
</filter>
<marker id="right-arrow" viewBox="0 0 12 12" refX="11" refY="6" markerWidth="10" markerHeight="10" orient="auto">
<path d="M 0 1 L 11 6 L 0 11 z" fill="#3f6b78"/>
</marker>
<marker id="left-arrow" viewBox="0 0 12 12" refX="11" refY="6" markerWidth="10" markerHeight="10" orient="auto">
<path d="M 0 1 L 11 6 L 0 11 z" fill="#6c8f9c"/>
</marker>
<style>
.title { font: 700 44px Inter, Arial, sans-serif; fill: #17324a; }
.subtitle { font: 400 23px Inter, Arial, sans-serif; fill: #597080; }
.actor { font: 700 22px Inter, Arial, sans-serif; fill: #17324a; text-anchor: middle; }
.role { font: 600 16px Inter, Arial, sans-serif; fill: #6b7d8d; letter-spacing: 1.4px; text-anchor: middle; }
.label { font: 600 18px Inter, Arial, sans-serif; fill: #34566a; }
.code { font: 600 17px Menlo, Consolas, monospace; fill: #236779; }
.note { font: 400 18px Inter, Arial, sans-serif; fill: #466174; }
</style>
</defs>
<rect width="1600" height="900" fill="url(#bg)"/>
<text x="104" y="110" class="title">Приватный файл: доступ проверяется до чтения с диска</text>
<text x="104" y="152" class="subtitle">URL содержит ID записи. Настоящий ключ и путь остаются за маршрутом приложения.</text>
<g filter="url(#shadow)">
<rect x="112" y="238" width="236" height="108" rx="22" fill="#ffffff"/>
<text x="230" y="282" class="actor">Браузер</text>
<text x="230" y="314" class="role">ПОЛЬЗОВАТЕЛЬ</text>
<rect x="466" y="238" width="250" height="108" rx="22" fill="#ffffff"/>
<text x="591" y="282" class="actor">Маршрут PHP</text>
<text x="591" y="314" class="role">АВТОРИЗАЦИЯ</text>
<rect x="837" y="238" width="248" height="108" rx="22" fill="#ffffff"/>
<text x="961" y="282" class="actor">documents</text>
<text x="961" y="314" class="role">БАЗА ДАННЫХ</text>
<rect x="1210" y="238" width="280" height="108" rx="22" fill="#ffffff"/>
<text x="1350" y="282" class="actor">Закрытый каталог</text>
<text x="1350" y="314" class="role">ФАЙЛОВАЯ СИСТЕМА</text>
</g>
<line x1="230" y1="346" x2="230" y2="708" stroke="#b8cbd5" stroke-width="3" stroke-dasharray="9 10"/>
<line x1="591" y1="346" x2="591" y2="708" stroke="#b8cbd5" stroke-width="3" stroke-dasharray="9 10"/>
<line x1="961" y1="346" x2="961" y2="708" stroke="#b8cbd5" stroke-width="3" stroke-dasharray="9 10"/>
<line x1="1350" y1="346" x2="1350" y2="708" stroke="#b8cbd5" stroke-width="3" stroke-dasharray="9 10"/>
<path d="M230 414 H580" fill="none" stroke="#3f6b78" stroke-width="6" stroke-linecap="round" marker-end="url(#right-arrow)"/>
<text x="308" y="398" class="label">1. GET</text>
<text x="308" y="430" class="code">/documents/42/download</text>
<path d="M591 490 H950" fill="none" stroke="#3f6b78" stroke-width="6" stroke-linecap="round" marker-end="url(#right-arrow)"/>
<text x="684" y="474" class="label">2. Проверка владельца</text>
<text x="684" y="506" class="code">id + owner_id + status</text>
<path d="M961 562 H1339" fill="none" stroke="#3f6b78" stroke-width="6" stroke-linecap="round" marker-end="url(#right-arrow)"/>
<text x="1069" y="546" class="label">3. Найден ключ</text>
<text x="1069" y="578" class="code">storage_key.pdf</text>
<path d="M1339 634 H602" fill="none" stroke="#6c8f9c" stroke-width="6" stroke-linecap="round" marker-end="url(#left-arrow)"/>
<text x="1000" y="618" class="label">4. PHP читает только закрытый путь</text>
<path d="M580 706 H241" fill="none" stroke="#27846e" stroke-width="7" stroke-linecap="round" marker-end="url(#left-arrow)"/>
<text x="314" y="689" class="label">5. HTTP-ответ</text>
<text x="314" y="724" class="code">Content-Type: application/pdf</text>
<rect x="112" y="777" width="1378" height="66" rx="18" fill="#ffffff" stroke="#c9dcda" stroke-width="2"/>
<text x="146" y="818" class="note">Прямого URL к каталогу нет: запись в базе связывает пользователя с ключом, а маршрут решает, можно ли читать файл.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -0,0 +1,91 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" role="img" aria-labelledby="title desc">
<title id="title">Контракт безопасной загрузки аватара</title>
<desc id="desc">Схема из четырёх шагов: браузер, временный файл PHP, проверка, закрытое хранилище.</desc>
<defs>
<linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#f6f8fc"/>
<stop offset="1" stop-color="#eef4f3"/>
</linearGradient>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="14" stdDeviation="18" flood-color="#123049" flood-opacity=".12"/>
</filter>
<marker id="arrow" viewBox="0 0 12 12" refX="11" refY="6" markerWidth="10" markerHeight="10" orient="auto">
<path d="M 0 1 L 11 6 L 0 11 z" fill="#6b7d8d"/>
</marker>
<style>
.title { font: 700 44px Inter, Arial, sans-serif; fill: #17324a; }
.subtitle { font: 400 23px Inter, Arial, sans-serif; fill: #597080; }
.step { font: 700 18px Inter, Arial, sans-serif; fill: #75909f; letter-spacing: 1.8px; }
.card-title { font: 700 28px Inter, Arial, sans-serif; fill: #17324a; }
.card-text { font: 400 20px Inter, Arial, sans-serif; fill: #466174; }
.code { font: 600 18px Menlo, Consolas, monospace; fill: #1f6672; }
.badge { font: 700 16px Inter, Arial, sans-serif; fill: #2e5267; }
.note { font: 600 18px Inter, Arial, sans-serif; fill: #36536a; }
</style>
</defs>
<rect width="1600" height="900" fill="url(#background)"/>
<path d="M0 684 C260 613 404 779 643 692 S1139 590 1600 717 V900 H0Z" fill="#e4f0ef"/>
<text x="106" y="116" class="title">Загрузка аватара: решение принимается до переноса</text>
<text x="106" y="158" class="subtitle">Клиент присылает файл. Приложение выбирает допустимый тип, размеры и собственный ключ.</text>
<line x1="390" y1="435" x2="481" y2="435" stroke="#6b7d8d" stroke-width="7" stroke-linecap="round" marker-end="url(#arrow)"/>
<line x1="765" y1="435" x2="856" y2="435" stroke="#6b7d8d" stroke-width="7" stroke-linecap="round" marker-end="url(#arrow)"/>
<line x1="1140" y1="435" x2="1231" y2="435" stroke="#6b7d8d" stroke-width="7" stroke-linecap="round" marker-end="url(#arrow)"/>
<g filter="url(#shadow)">
<rect x="106" y="286" width="284" height="302" rx="24" fill="#ffffff"/>
<rect x="106" y="286" width="284" height="12" rx="6" fill="#ea8847"/>
<circle cx="154" cy="344" r="25" fill="#fce3d1"/>
<text x="146" y="351" class="badge" fill="#c8692b">1</text>
<text x="194" y="351" class="step">КЛИЕНТ</text>
<text x="142" y="412" class="card-title">Форма</text>
<text x="142" y="452" class="code">name=avatar</text>
<text x="142" y="496" class="card-text">имя и Content-Type</text>
<text x="142" y="526" class="card-text">остаются входом,</text>
<text x="142" y="556" class="card-text">а не решением.</text>
</g>
<g filter="url(#shadow)">
<rect x="481" y="286" width="284" height="302" rx="24" fill="#ffffff"/>
<rect x="481" y="286" width="284" height="12" rx="6" fill="#6994c6"/>
<circle cx="529" cy="344" r="25" fill="#dceafa"/>
<text x="521" y="351" class="badge" fill="#4374af">2</text>
<text x="569" y="351" class="step">PHP</text>
<text x="517" y="412" class="card-title">Временный файл</text>
<text x="517" y="452" class="code">$_FILES</text>
<text x="517" y="496" class="card-text">error · size · tmp_name</text>
<text x="517" y="526" class="card-text">Сначала убеждаемся,</text>
<text x="517" y="556" class="card-text">что доставка завершилась.</text>
</g>
<g filter="url(#shadow)">
<rect x="856" y="286" width="284" height="302" rx="24" fill="#ffffff"/>
<rect x="856" y="286" width="284" height="12" rx="6" fill="#4eaa93"/>
<circle cx="904" cy="344" r="25" fill="#d6f1e8"/>
<text x="896" y="351" class="badge" fill="#27846e">3</text>
<text x="944" y="351" class="step">ПРОВЕРКА</text>
<text x="892" y="412" class="card-title">Контракт</text>
<text x="892" y="452" class="code">finfo_file()</text>
<text x="892" y="496" class="card-text">JPEG / PNG · 2 МБ</text>
<text x="892" y="526" class="card-text">размеры изображения</text>
<text x="892" y="556" class="card-text">и белый список.</text>
</g>
<g filter="url(#shadow)">
<rect x="1231" y="286" width="284" height="302" rx="24" fill="#ffffff"/>
<rect x="1231" y="286" width="284" height="12" rx="6" fill="#5d7f90"/>
<circle cx="1279" cy="344" r="25" fill="#dce8ed"/>
<text x="1271" y="351" class="badge" fill="#39586b">4</text>
<text x="1319" y="351" class="step">ХРАНИЛИЩЕ</text>
<text x="1267" y="412" class="card-title">Закрытый каталог</text>
<text x="1267" y="452" class="code">7f4a...c2.png</text>
<text x="1267" y="496" class="card-text">свой ключ приложения,</text>
<text x="1267" y="526" class="card-text">никакого пути из</text>
<text x="1267" y="556" class="card-text">исходного имени.</text>
</g>
<rect x="106" y="688" width="1409" height="88" rx="20" fill="#ffffff" stroke="#c9dcda" stroke-width="2"/>
<circle cx="158" cy="732" r="18" fill="#4eaa93"/>
<path d="M149 732 l7 7 l13 -16" fill="none" stroke="#ffffff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<text x="202" y="740" class="note">Перенос происходит только после проверки: из браузера в каталог попадает не «файл с именем», а запись, соответствующая контракту.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

@@ -0,0 +1,86 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" role="img" aria-labelledby="title desc">
<title id="title">Границы доверия при загрузке файла</title>
<desc id="desc">Диаграмма показывает клиентские метаданные, результат PHP, Fileinfo и решение приложения.</desc>
<defs>
<linearGradient id="panel" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="1" stop-color="#f5f8fb"/>
</linearGradient>
<filter id="soft-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#142b3d" flood-opacity=".10"/>
</filter>
<marker id="flow-arrow" viewBox="0 0 12 12" refX="11" refY="6" markerWidth="10" markerHeight="10" orient="auto">
<path d="M 0 1 L 11 6 L 0 11 z" fill="#47687b"/>
</marker>
<style>
.title { font: 700 44px Inter, Arial, sans-serif; fill: #17324a; }
.subtitle { font: 400 23px Inter, Arial, sans-serif; fill: #597080; }
.lane { font: 700 17px Inter, Arial, sans-serif; letter-spacing: 2.2px; }
.box-title { font: 700 27px Inter, Arial, sans-serif; fill: #17324a; }
.box-copy { font: 400 20px Inter, Arial, sans-serif; fill: #466174; }
.mono { font: 600 18px Menlo, Consolas, monospace; fill: #2f6174; }
.small { font: 600 16px Inter, Arial, sans-serif; fill: #6b7d8d; }
</style>
</defs>
<rect width="1600" height="900" fill="#f5f8fb"/>
<rect x="0" y="0" width="500" height="900" fill="#fff4ed"/>
<rect x="500" y="0" width="520" height="900" fill="#eef5fb"/>
<rect x="1020" y="0" width="580" height="900" fill="#eef8f4"/>
<text x="94" y="104" class="title">Загрузка — это не один сигнал, а несколько границ</text>
<text x="94" y="146" class="subtitle">Слева — то, что заявил клиент. Справа — то, на чём приложение строит решение.</text>
<text x="106" y="226" class="lane" fill="#c5682d">СВЕДЕНИЯ КЛИЕНТА</text>
<text x="605" y="226" class="lane" fill="#4579a8">РЕЗУЛЬТАТ PHP</text>
<text x="1125" y="226" class="lane" fill="#27846e">РЕШЕНИЕ ПРИЛОЖЕНИЯ</text>
<line x1="500" y1="184" x2="500" y2="774" stroke="#d78253" stroke-width="3" stroke-dasharray="10 12"/>
<line x1="1020" y1="184" x2="1020" y2="774" stroke="#65a18f" stroke-width="3" stroke-dasharray="10 12"/>
<g filter="url(#soft-shadow)">
<rect x="106" y="278" width="300" height="130" rx="20" fill="url(#panel)"/>
<text x="138" y="326" class="box-title">Имя и расширение</text>
<text x="138" y="364" class="mono">avatar.jpg</text>
<text x="138" y="394" class="box-copy">Удобны для интерфейса.</text>
<rect x="106" y="452" width="300" height="130" rx="20" fill="url(#panel)"/>
<text x="138" y="500" class="box-title">Content-Type</text>
<text x="138" y="538" class="mono">image/jpeg</text>
<text x="138" y="568" class="box-copy">Это поле multipart-части.</text>
</g>
<g filter="url(#soft-shadow)">
<rect x="606" y="278" width="310" height="130" rx="20" fill="url(#panel)"/>
<text x="638" y="326" class="box-title">Доставка</text>
<text x="638" y="364" class="mono">UPLOAD_ERR_OK</text>
<text x="638" y="394" class="box-copy">PHP принял файл целиком.</text>
<rect x="606" y="452" width="310" height="130" rx="20" fill="url(#panel)"/>
<text x="638" y="500" class="box-title">Временный файл</text>
<text x="638" y="538" class="mono">tmp_name · size</text>
<text x="638" y="568" class="box-copy">Есть что проверять на сервере.</text>
</g>
<g filter="url(#soft-shadow)">
<rect x="1126" y="278" width="354" height="130" rx="20" fill="url(#panel)"/>
<text x="1158" y="326" class="box-title">Fileinfo</text>
<text x="1158" y="364" class="mono">finfo_file()</text>
<text x="1158" y="394" class="box-copy">Определяет тип временного файла.</text>
<rect x="1126" y="452" width="354" height="130" rx="20" fill="url(#panel)"/>
<text x="1158" y="500" class="box-title">Белый список</text>
<text x="1158" y="538" class="mono">JPEG / PNG / 2 МБ</text>
<text x="1158" y="568" class="box-copy">Правило конкретного сценария.</text>
</g>
<path d="M406 343 C475 343 516 343 606 343" fill="none" stroke="#d78253" stroke-width="6" stroke-linecap="round" marker-end="url(#flow-arrow)"/>
<path d="M406 517 C480 517 532 517 606 517" fill="none" stroke="#d78253" stroke-width="6" stroke-linecap="round" marker-end="url(#flow-arrow)"/>
<path d="M916 343 C985 343 1051 343 1126 343" fill="none" stroke="#4e8ab8" stroke-width="6" stroke-linecap="round" marker-end="url(#flow-arrow)"/>
<path d="M916 517 C985 517 1051 517 1126 517" fill="none" stroke="#4e8ab8" stroke-width="6" stroke-linecap="round" marker-end="url(#flow-arrow)"/>
<rect x="106" y="665" width="1374" height="102" rx="22" fill="#ffffff" stroke="#cfdfd9" stroke-width="2"/>
<circle cx="156" cy="716" r="19" fill="#27846e"/>
<path d="M147 716 l7 7 l14 -17" fill="none" stroke="#ffffff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<text x="203" y="708" class="box-title">Практическое правило</text>
<text x="203" y="742" class="box-copy">Имя и клиентский MIME-тип не определяют допуск. Они становятся полезными только после того, как серверный анализ и белый список дали ответ.</text>
<text x="106" y="829" class="small">Отдельная проверка размеров дополняет контракт изображения, но не заменяет проверку типа.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

+55 -8
View File
@@ -1,21 +1,38 @@
import { access, readFile } from 'node:fs/promises'; import { access, readFile } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { editorialRevisions } from '../data/editorial-revisions.mjs';
const webRoot = join(fileURLToPath(new URL('..', import.meta.url))); const webRoot = join(fileURLToPath(new URL('..', import.meta.url)));
const articlesPath = join(webRoot, 'data', 'articles.json'); const articlesPath = join(webRoot, 'data', 'articles.json');
const slugs = process.argv.slice(2); const archivedArticles = JSON.parse(await readFile(articlesPath, 'utf8'));
const revisionBySlug = new Map(
editorialRevisions.map((revision) => [revision.slug, revision]),
);
const archive = archivedArticles.map((article) => ({
...article,
...revisionBySlug.get(article.slug),
}));
const requestedSlugs = process.argv.slice(2);
const slugs = requestedSlugs.includes('--all-editorial')
? archive.filter((article) => article.slug.startsWith('editorial-')).map((article) => article.slug)
: requestedSlugs;
if (slugs.length === 0) { if (slugs.length === 0) {
throw new Error('Usage: node scripts/audit-quality-batch.mjs <article-slug> [...slug]'); throw new Error('Usage: node scripts/audit-quality-batch.mjs <article-slug> [...slug] | --all-editorial');
} }
const archive = JSON.parse(await readFile(articlesPath, 'utf8'));
const genericPhrases = [ const genericPhrases = [
'У этой модели нет магической силы', 'У этой модели нет магической силы',
'Материалы для проверки', 'Материалы для проверки',
'Если держать этот порядок, решение остаётся понятным', 'Если держать этот порядок, решение остаётся понятным',
'В современном мире',
'очень важно',
'следует отметить',
'просто нужно',
'нужно понимать, что',
]; ];
const MIN_BODY_CHARS = 5000;
const MAX_BODY_CHARS = 15000;
let failed = false; let failed = false;
function count(content, expression) { function count(content, expression) {
@@ -30,6 +47,12 @@ function plainText(content) {
.trim(); .trim();
} }
function bodyText(content) {
return plainText(
content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''),
);
}
for (const slug of slugs) { for (const slug of slugs) {
const article = archive.find((candidate) => candidate.slug === slug); const article = archive.find((candidate) => candidate.slug === slug);
const issues = []; const issues = [];
@@ -41,19 +64,43 @@ for (const slug of slugs) {
} }
const content = article.contentHtml; const content = article.contentHtml;
const text = plainText(content); const body = bodyText(content);
const imageSources = [...content.matchAll(/<img[^>]+src="([^"]+)"/g)].map((match) => match[1]); const imageSources = [...content.matchAll(/<img[^>]+src="([^"]+)"/g)].map((match) => match[1]);
const figures = [...content.matchAll(/<figure>([\s\S]*?)<\/figure>/g)].map((match) => match[1]);
if (article.readingMinutes < 8) issues.push('указано меньше 8 минут чтения'); if (article.readingMinutes < 8) issues.push('указано меньше 8 минут чтения');
if (text.length < 4800) issues.push('меньше 4800 символов осмысленного текста'); if (body.length < MIN_BODY_CHARS) {
issues.push('меньше ' + MIN_BODY_CHARS + ' знаков основного текста');
}
if (body.length > MAX_BODY_CHARS) {
issues.push('больше ' + MAX_BODY_CHARS + ' знаков основного текста');
}
if (count(content, /<h2>/g) < 5) issues.push('меньше пяти смысловых разделов'); if (count(content, /<h2>/g) < 5) issues.push('меньше пяти смысловых разделов');
if (count(content, /<figure>/g) < 1 || imageSources.length < 1) issues.push('нет визуального объяснения'); if (count(content, /<figure>/g) < 1 || imageSources.length < 1) issues.push('нет визуального объяснения');
if (count(content, /<figcaption>/g) < 1) issues.push('у иллюстрации нет подписи'); if (count(content, /<figcaption>/g) < 1) issues.push('у иллюстрации нет подписи');
if (count(content, /<table>/g) < 1 || count(content, /<thead>/g) < 1) issues.push('нет доступной таблицы'); if (count(content, /<table>/g) < 1 || count(content, /<thead>/g) < 1) issues.push('нет доступной таблицы');
if (count(content, /<pre><code>/g) < 1) issues.push('нет воспроизводимого примера'); if (count(content, /<pre><code>/g) < 1) issues.push('нет воспроизводимого примера');
if (count(content, /<ol>/g) < 1) issues.push('нет последовательности проверки или действий');
if (count(content, /<a href="https?:\/\//g) < 2) issues.push('меньше двух внешних источников'); if (count(content, /<a href="https?:\/\//g) < 2) issues.push('меньше двух внешних источников');
if (!content.includes('<h2>Проверяемые источники</h2>')) issues.push('нет отдельного раздела с источниками'); if (!content.includes('<h2>Проверяемые источники</h2>')) issues.push('нет отдельного раздела с источниками');
if (content.includes('undefined') || content.includes('[object Object]')) issues.push('в тексте есть след генерации'); const proseWithoutCode = content
.replace(/<pre><code>[\s\S]*?<\/code><\/pre>/g, '')
.replace(/<code>[\s\S]*?<\/code>/g, '');
if (proseWithoutCode.includes('undefined') || proseWithoutCode.includes('[object Object]')) {
issues.push('в тексте есть след генерации');
}
if (!/(проблем|ошиб|симптом|сбой|задач)/i.test(body.slice(0, 800))) {
issues.push('проблема не названа в начале текста');
}
for (const figure of figures) {
const image = figure.match(/<img\b[^>]*>/);
const alt = image?.[0].match(/\balt="([^"]*)"/);
if (!image || !alt || alt[1].trim() === '') {
issues.push('у рисунка нет содержательного alt-текста');
break;
}
}
for (const phrase of genericPhrases) { for (const phrase of genericPhrases) {
if (content.includes(phrase)) issues.push('обнаружен шаблонный оборот: «' + phrase + '»'); if (content.includes(phrase)) issues.push('обнаружен шаблонный оборот: «' + phrase + '»');
@@ -73,7 +120,7 @@ for (const slug of slugs) {
} else { } else {
console.log( console.log(
'PASS ' + slug 'PASS ' + slug
+ ': ' + text.length + ' chars, ' + ': ' + body.length + ' body chars, '
+ count(content, /<figure>/g) + ' figure, ' + count(content, /<figure>/g) + ' figure, '
+ count(content, /<table>/g) + ' table, ' + count(content, /<table>/g) + ' table, '
+ count(content, /<pre><code>/g) + ' code example', + count(content, /<pre><code>/g) + ' code example',
+9 -1
View File
@@ -92,6 +92,7 @@ const practiceArticle = {
figure('/assets/editorial/2018/bitrix-catalog-workflow.png', 'Разработчик проверяет путь от формы к карточке товара и фиксирует схему процесса', 'Не начинаем с большого импорта. Сначала рисуем путь данных и называем контрольные точки.'), figure('/assets/editorial/2018/bitrix-catalog-workflow.png', 'Разработчик проверяет путь от формы к карточке товара и фиксирует схему процесса', 'Не начинаем с большого импорта. Сначала рисуем путь данных и называем контрольные точки.'),
heading('Сначала формулируем контракт операции'), heading('Сначала формулируем контракт операции'),
paragraph('Перед вызовом API полезно выписать, какие поля обязательны именно для нашего инфоблока. Это выглядит занудно до первой ошибки импорта, а после неё экономит часы. Не нужно создавать универсальный валидатор Bitrix: достаточно проверить входные данные, зафиксировать системные значения и вернуть диагностируемую ошибку вызывающему коду.'), paragraph('Перед вызовом API полезно выписать, какие поля обязательны именно для нашего инфоблока. Это выглядит занудно до первой ошибки импорта, а после неё экономит часы. Не нужно создавать универсальный валидатор Bitrix: достаточно проверить входные данные, зафиксировать системные значения и вернуть диагностируемую ошибку вызывающему коду.'),
paragraph('Ещё один пункт контракта — повторный запуск. Импорт может оборваться после ответа базы или до записи в журнал. Поэтому внешний идентификатор должен позволять отличить новую операцию от повтора. В проекте это обычно означает отдельную проверку существующей записи по внешнему ключу и явное правило: обновляем черновик, пропускаем готовую запись или останавливаемся с конфликтом. Сам <code>Add</code> за это правило не отвечает.'),
dataTable( dataTable(
['Участок', 'Что фиксируем', 'Чем доказываем'], ['Участок', 'Что фиксируем', 'Чем доказываем'],
[ [
@@ -185,7 +186,7 @@ const mechanismArticle = {
excerpt: 'Разбираем жизненный цикл добавления элемента: кто проверяет поля, где срабатывают события Bitrix, почему глобальный обработчик не заменяет сервис и как тестировать эту границу.', excerpt: 'Разбираем жизненный цикл добавления элемента: кто проверяет поля, где срабатывают события Bitrix, почему глобальный обработчик не заменяет сервис и как тестировать эту границу.',
readingMinutes: 9, readingMinutes: 9,
contentHtml: [ contentHtml: [
paragraph('Когда Bitrix-проект разрастается, вокруг простого <code>CIBlockElement::Add</code> появляется невидимый код: обработчики событий, правила символьного кода, импортеры, каталог, поиск и шаблоны. Из-за этого одинаковый вызов сегодня работает из формы, а завтра падает из консольного скрипта. Давайте разложим путь записи по шагам и не будем прятать бизнес-правило в месте, где его трудно обнаружить.'), paragraph('Проблема появляется, когда Bitrix-проект разрастается вокруг простого <code>CIBlockElement::Add</code>: обработчики событий, правила символьного кода, импортеры, каталог, поиск и шаблоны становятся невидимой частью вызова. Из-за этого одинаковый код сегодня работает из формы, а завтра падает из консольного скрипта. Давайте разложим путь записи по шагам и не будем прятать бизнес-правило в месте, где его трудно обнаружить.'),
heading('Карта жизненного цикла'), heading('Карта жизненного цикла'),
paragraph('Документация Bitrix говорит важную вещь: перед добавлением вызывается <code>OnBeforeIBlockElementAdd</code>. Обработчик получает поля по ссылке, поэтому способен их изменить; чтобы отменить запись, он должен установить исключение через <code>$APPLICATION-&gt;ThrowException()</code> и вернуть <code>false</code>. После успешной записи срабатывают события после добавления. Это значит, что обработчик — реальная часть контракта метода, а не декоративная «магия в init.php».'), paragraph('Документация Bitrix говорит важную вещь: перед добавлением вызывается <code>OnBeforeIBlockElementAdd</code>. Обработчик получает поля по ссылке, поэтому способен их изменить; чтобы отменить запись, он должен установить исключение через <code>$APPLICATION-&gt;ThrowException()</code> и вернуть <code>false</code>. После успешной записи срабатывают события после добавления. Это значит, что обработчик — реальная часть контракта метода, а не декоративная «магия в init.php».'),
figure('/assets/editorial/2018/bitrix-add-lifecycle.svg', 'Последовательность от формы до контрольной публичной выборки при создании элемента Bitrix', 'ID возвращается из слоя инфоблока, но качество результата подтверждается уже в пользовательском сценарии.'), figure('/assets/editorial/2018/bitrix-add-lifecycle.svg', 'Последовательность от формы до контрольной публичной выборки при создании элемента Bitrix', 'ID возвращается из слоя инфоблока, но качество результата подтверждается уже в пользовательском сценарии.'),
@@ -256,6 +257,12 @@ const mechanismArticle = {
heading('После записи — это уже другой разговор'), heading('После записи — это уже другой разговор'),
paragraph('Обработчик после добавления удобен для журналирования, запуска поиска или отправки внутреннего уведомления. Но он не должен молча решать судьбу уже созданного элемента. Если побочный шаг упал после того, как <code>Add</code> вернул ID, повторный вызов <code>Add</code> из обработчика легко создаст дубль, а внешний сервис получит два одинаковых запроса. Поэтому после записи я бы сохранял ID, внешний ключ и понятный статус операции, а ошибку реакции разбирал как отдельную задачу.'), paragraph('Обработчик после добавления удобен для журналирования, запуска поиска или отправки внутреннего уведомления. Но он не должен молча решать судьбу уже созданного элемента. Если побочный шаг упал после того, как <code>Add</code> вернул ID, повторный вызов <code>Add</code> из обработчика легко создаст дубль, а внешний сервис получит два одинаковых запроса. Поэтому после записи я бы сохранял ID, внешний ключ и понятный статус операции, а ошибку реакции разбирал как отдельную задачу.'),
paragraph('Если синхронизация с внешней системой действительно обязательна для публикации товара, полезно разделить два состояния: «элемент сохранён» и «элемент готов для пользователя». Первый факт подтверждает сервис создания, второй — контрольная проверка после всех зависимых действий. Тогда временный сбой индексации или уведомления не превращается в неясную историю, где никто не понимает, можно ли безопасно повторить импорт.'), paragraph('Если синхронизация с внешней системой действительно обязательна для публикации товара, полезно разделить два состояния: «элемент сохранён» и «элемент готов для пользователя». Первый факт подтверждает сервис создания, второй — контрольная проверка после всех зависимых действий. Тогда временный сбой индексации или уведомления не превращается в неясную историю, где никто не понимает, можно ли безопасно повторить импорт.'),
heading('Три вопроса до запуска'),
orderedList([
'Какой инвариант действительно общий для всех способов создания элемента, а какой относится только к форме или импорту?',
'Где вызывающий код получит причину отказа: в результате сервиса, в <code>LAST_ERROR</code> или в отдельном журнале операции?',
'Как повторный запуск отличит новую запись от уже созданной и не превратит сбой обработчика в дубликат?',
]),
heading('Как тестировать такую связку'), heading('Как тестировать такую связку'),
paragraph('В 2018-м легко ограничиться ручной проверкой в админке, но здесь полезно хотя бы зафиксировать короткую матрицу. Она не требует сложного тестового фреймворка: часть сценариев можно выполнить на тестовом инфоблоке и сохранить как чек-лист релиза. Главное — проверять и прямой сервис, и поведение глобального события.'), paragraph('В 2018-м легко ограничиться ручной проверкой в админке, но здесь полезно хотя бы зафиксировать короткую матрицу. Она не требует сложного тестового фреймворка: часть сценариев можно выполнить на тестовом инфоблоке и сохранить как чек-лист релиза. Главное — проверять и прямой сервис, и поведение глобального события.'),
dataTable( dataTable(
@@ -285,6 +292,7 @@ const fieldArticle = {
paragraph('Знакомая картина: скрипт вернул ID, в админке новый товар есть, а на сайте его нет. Первый импульс — «почистить кеш». Иногда это действительно помогает, но чаще кеш просто оказывается первым подозреваемым, потому что его легко назвать. Давайте сначала отделим факт записи от публичной видимости и пройдём путь теми же условиями, которыми живёт каталог.'), paragraph('Знакомая картина: скрипт вернул ID, в админке новый товар есть, а на сайте его нет. Первый импульс — «почистить кеш». Иногда это действительно помогает, но чаще кеш просто оказывается первым подозреваемым, потому что его легко назвать. Давайте сначала отделим факт записи от публичной видимости и пройдём путь теми же условиями, которыми живёт каталог.'),
heading('Постановка проблемы'), heading('Постановка проблемы'),
paragraph('Админка и публичный компонент редко показывают одинаковую выборку. Админка может отобразить неактивный элемент, а каталог фильтрует по <code>ACTIVE</code>, датам активности, разделу, правам, цене, наличию и проектным свойствам. Поэтому вопрос «почему элемент не виден?» нельзя решать одной командой. Нужен короткий список слоёв и доказательство на каждом.'), paragraph('Админка и публичный компонент редко показывают одинаковую выборку. Админка может отобразить неактивный элемент, а каталог фильтрует по <code>ACTIVE</code>, датам активности, разделу, правам, цене, наличию и проектным свойствам. Поэтому вопрос «почему элемент не виден?» нельзя решать одной командой. Нужен короткий список слоёв и доказательство на каждом.'),
paragraph('Полезно сразу сохранить два разных наблюдения: «запись читается по ID без ограничений» и «запись попадает в публичную выборку». Между ними могут стоять несколько независимых условий. Если журнал хранит только успешный ID, а не фильтр и результат контрольного запроса, следующему разработчику останется лишь гадать, какая граница исключила товар.'),
figure('/assets/editorial/2018/bitrix-visibility-diagnostic.svg', 'Дерево диагностики: от результата Add к условиям публичного каталога', 'Начинаем не с кеша, а с самого раннего условия, которое может исключить элемент из публичной выборки.'), figure('/assets/editorial/2018/bitrix-visibility-diagnostic.svg', 'Дерево диагностики: от результата Add к условиям публичного каталога', 'Начинаем не с кеша, а с самого раннего условия, которое может исключить элемент из публичной выборки.'),
heading('Проверяем по слоям'), heading('Проверяем по слоям'),
dataTable( dataTable(
+498
View File
@@ -0,0 +1,498 @@
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(text) {
return '<pre><code>' + escapeHtml(text.trim()) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function bulletList(items) {
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
}
function dataTable(headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
const phpSetErrorHandler = {
title: 'PHP manual: set_error_handler',
url: 'https://www.php.net/manual/en/function.set-error-handler.php',
note: 'какие ошибки передаются пользовательскому обработчику и какие типы он не перехватывает',
};
const phpExceptionHandler = {
title: 'PHP manual: set_exception_handler',
url: 'https://www.php.net/manual/en/function.set-exception-handler.php',
note: 'обработчик непойманного Throwable, который получает Error и Exception',
};
const phpShutdown = {
title: 'PHP manual: register_shutdown_function',
url: 'https://www.php.net/manual/en/function.register-shutdown-function.php',
note: 'когда PHP вызывает зарегистрированную функцию завершения',
};
const phpLastError = {
title: 'PHP manual: error_get_last',
url: 'https://www.php.net/manual/en/function.error-get-last.php',
note: 'формат последней ошибки: type, message, file и line',
};
const phpCurlExec = {
title: 'PHP manual: curl_exec',
url: 'https://www.php.net/manual/en/function.curl-exec.php',
note: 'строгое сравнение с false и отличие ошибки cURL от HTTP-статуса',
};
const phpCurlInfo = {
title: 'PHP manual: curl_getinfo',
url: 'https://www.php.net/manual/en/function.curl-getinfo.php',
note: 'данные последней передачи, включая http_code, content_type и total_time',
};
const phpCurlErrno = {
title: 'PHP manual: curl_errno',
url: 'https://www.php.net/manual/en/function.curl-errno.php',
note: 'код последней ошибки cURL и ноль при отсутствии ошибки',
};
const httpSemantics = {
title: 'RFC 7231, раздел 6: Response Status Codes',
url: 'https://www.rfc-editor.org/rfc/rfc7231#section-6',
note: 'семантика статус-кодов HTTP на уровне протокола',
};
const phpJsonDecode = {
title: 'PHP manual: json_decode',
url: 'https://www.php.net/manual/en/function.json-decode.php',
note: 'что возвращает декодер, требование UTF-8 и изменение PHP 7.3',
};
const phpJsonLastError = {
title: 'PHP manual: json_last_error',
url: 'https://www.php.net/manual/en/function.json-last-error.php',
note: 'коды ошибок последней операции JSON',
};
const jsonRfc = {
title: 'RFC 8259: JSON',
url: 'https://www.rfc-editor.org/rfc/rfc8259.html',
note: 'JSON допускает не только объект и массив, но и null, false, true, число и строку',
};
const practiceArticle = {
slug: 'editorial-2018-02-practice-php-diagnostics',
title: 'PHP. Как записать причину 500-й ошибки в интеграции',
categories: ['PHP', 'Отладка', 'Интеграции'],
cover: '/assets/editorial/2018/php-fatal-context-flow.svg',
excerpt: 'Разбираю, как собрать один полезный диагностический факт при фатальной ошибке PHP: где работает set_error_handler, зачем нужен shutdown-обработчик и какие данные нельзя писать в лог.',
readingMinutes: 10,
contentHtml: [
paragraph('Интеграционный endpoint вернул 500, а в журнале осталась только дата и адрес скрипта. На следующий день партнёр повторяет запрос, но уже с другими данными, и причина исчезает. В такой ситуации не помогает ещё один <code>try/catch</code> вокруг вызова API: часть ошибок PHP до него не дойдёт. Вопрос этой заметки простой: как оставить один диагностический факт с операцией и местом падения, не превращая журнал в копию чужого запроса?'),
heading('Почему одного set_error_handler недостаточно'),
paragraph('Первое, что обычно хочется сделать, — повесить <code>set_error_handler</code> и считать задачу закрытой. У функции есть граница: пользовательский обработчик не получает <code>E_ERROR</code>, <code>E_PARSE</code>, <code>E_CORE_ERROR</code> и <code>E_COMPILE_ERROR</code>. Он также не может увидеть ошибку, случившуюся до регистрации обработчика. Это не дефект функции, а условие, от которого надо строить диагностику.'),
paragraph('Поэтому я разделяю три случая. Обычное предупреждение попадает в обработчик ошибок. Непойманное исключение или <code>Error</code> в PHP 7 попадает в обработчик исключений. Для части фатальных ошибок остаётся функция завершения: PHP вызывает её после окончания скрипта или после <code>exit()</code>, а <code>error_get_last()</code> даёт тип, сообщение, файл и строку последней ошибки. Функция завершения не заменяет нормальную обработку исключений, но закрывает именно этот зазор.'),
figure('/assets/editorial/2018/php-fatal-context-flow.svg', 'Схема: контекст операции создаётся перед интеграцией; предупреждение идёт в set_error_handler, исключение — в set_exception_handler, фатальная ошибка проверяется при shutdown', 'Один request ID проходит через все три ветки. В журнале видно не только текст PHP, но и операцию, на которой он возник.'),
heading('Сначала определить, что именно нужно найти потом'),
paragraph('Лог полезен, если по одной записи можно ответить на четыре вопроса: какая операция шла, какой внешний идентификатор обрабатывался, где остановился код и какой класс ошибки случился. Записывать целиком <code>$_POST</code>, заголовок авторизации или ответ партнёра для этого не нужно. В них часто лежат пароли, персональные данные и токены; при расследовании такой журнал создаёт вторую проблему.'),
paragraph('Для импорта заказа я оставляю короткий контекст: случайный ID операции, имя интеграции, внешний ID заказа и этап. Этап меняется перед опасным участком: <code>request_prepared</code>, <code>partner_called</code>, <code>response_saved</code>. Если процесс оборвался, последняя метка намного полезнее догадки по номеру строки.'),
dataTable(
['Поле журнала', 'Пример', 'Зачем оно нужно'],
[
['<code>request_id</code>', '<code>sync-20180207-4f2a</code>', 'Связать запись PHP с логом веб-сервера и сообщением партнёра'],
['<code>operation</code>', '<code>order_export</code>', 'Не смешать импорт каталога, webhook и ручной запуск'],
['<code>external_id</code>', '<code>ORD-9182</code>', 'Повторить один сценарий без поиска по всему набору данных'],
['<code>stage</code>', '<code>partner_called</code>', 'Понять, успел ли код дойти до внешнего вызова'],
['<code>error_type</code>', '<code>E_ERROR</code> или <code>Throwable</code>', 'Отделить ошибку PHP от ответа HTTP'],
['<code>file</code>, <code>line</code>', 'путь и строка', 'Открыть точку падения в той версии кода, которая работала в момент сбоя'],
],
),
heading('Минимальная обвязка для PHP 7'),
paragraph('Ниже пример для одного HTTP-запроса. Он не пытается перехватить всё подряд и не меняет поведение штатного обработчика PHP: после записи предупреждения возвращается <code>false</code>. Это удобно на первом внедрении: существующие настройки <code>error_reporting</code> и журнал сервера остаются на месте, а рядом появляется структурированная запись для интеграции.'),
codeBlock(String.raw`
<?php
function writeIntegrationLog(array $record)
{
error_log(json_encode($record, JSON_UNESCAPED_UNICODE));
}
function installIntegrationDiagnostics($requestId, $operation, $externalId)
{
$context = array(
'request_id' => $requestId,
'operation' => $operation,
'external_id' => $externalId,
'stage' => 'started',
);
$setStage = function ($stage) use (&$context) {
$context['stage'] = $stage;
};
set_error_handler(function ($severity, $message, $file, $line) use (&$context) {
if (!(error_reporting() & $severity)) {
return false;
}
writeIntegrationLog($context + array(
'kind' => 'php_error',
'error_type' => $severity,
'message' => $message,
'file' => $file,
'line' => $line,
));
return false;
});
set_exception_handler(function (Throwable $error) use (&$context) {
writeIntegrationLog($context + array(
'kind' => 'uncaught_throwable',
'class' => get_class($error),
'message' => $error->getMessage(),
'file' => $error->getFile(),
'line' => $error->getLine(),
));
});
register_shutdown_function(function () use (&$context) {
$last = error_get_last();
$fatalTypes = array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR);
if ($last === null || !in_array($last['type'], $fatalTypes, true)) {
return;
}
writeIntegrationLog($context + array(
'kind' => 'fatal_error',
'error_type' => $last['type'],
'message' => $last['message'],
'file' => $last['file'],
'line' => $last['line'],
));
});
return $setStage;
}
$setStage = installIntegrationDiagnostics(
'sync-20180207-4f2a',
'order_export',
'ORD-9182'
);
$setStage('request_prepared');
// Здесь вызывается клиент партнёра.
$setStage('partner_called');
`),
paragraph('В настоящем коде генерация <code>request_id</code> и запись журнала обычно живут в приложении, а не в каждой интеграции. Здесь они оставлены рядом, чтобы видно было главное: контекст создаётся до внешнего вызова, а не в блоке обработки ошибки. Нельзя восстановить по фатальной ошибке то, что код не успел записать.'),
heading('Как проверить схему до аварии'),
paragraph('Проверять такую обвязку лучше не на боевом заказе. Для предупреждения достаточно отдельного скрипта с <code>trigger_error(&quot;diagnostic test&quot;, E_USER_WARNING)</code>. Для исключения — выбросить <code>RuntimeException</code> после установки этапа. Фатальный путь нужно запускать только в изолированной среде: ошибка, которую нельзя перехватить через <code>set_error_handler</code>, должна оставить запись из shutdown-функции, а сам тест не должен менять состояние сторонней системы.'),
orderedList([
'Добавить обвязку в точку входа до вызова клиента интеграции и задать <code>request_id</code>, операцию и внешний ID.',
'Запустить локальный сценарий с предупреждением и убедиться, что в журнале есть все поля таблицы, а штатное сообщение PHP не исчезло.',
'Запустить сценарий с непойманным исключением в отдельном endpoint и проверить запись с классом исключения и последним этапом.',
'В тестовой среде проверить фатальный случай после регистрации обработчиков и убедиться, что shutdown-запись не дублирует обычные предупреждения.',
'Открыть журнал с позиции человека, который не видел код: по одной строке должно быть понятно, какой внешний объект повторять и где смотреть дальше.',
]),
heading('Где эта схема заканчивается'),
paragraph('Она не ловит синтаксическую ошибку в файле, который не дал приложению стартовать: обработчики ещё не зарегистрированы. Она не гарантирует запись при принудительном завершении процесса. Она не заменяет мониторинг 500-х на уровне веб-сервера. И она не даёт права сохранять секреты в журнал. Для таких случаев остаются деплой-проверки, журналы окружения и правила маскирования данных.'),
paragraph('Ещё одна граница — дубли. Ошибка внутри <code>set_error_handler</code> и ошибка в shutdown-функции не должны сами вызвать бесконечный поток записей. Поэтому запись должна быть короткой, а логгер — максимально простым. Если для доставки лога нужен сетевой запрос, я бы не ставил его в shutdown-путь: при падении сети потеряем и исходную ошибку, и время на разбор.'),
heading('Порядок, который остаётся в проекте'),
paragraph('Сначала ставим контекст, затем меняем этапы перед побочными эффектами, потом отдельно видим предупреждение, исключение и фатальный случай. После этого ошибка 500 перестаёт быть сообщением «что-то не так». В ней есть операция, внешний объект, последняя пройденная граница и место в коде. Этого достаточно, чтобы воспроизвести проблему до следующего запроса партнёра.'),
heading('Проверяемые источники'),
sourceList([phpSetErrorHandler, phpExceptionHandler, phpShutdown, phpLastError]),
].join('\n'),
};
const mechanismArticle = {
slug: 'editorial-2018-02-mechanism-php-diagnostics',
title: 'PHP и cURL. Почему curl_exec() не означает успех интеграции',
categories: ['PHP', 'cURL', 'Интеграции'],
cover: '/assets/editorial/2018/curl-outcome-classifier.svg',
excerpt: 'curl_exec() может вернуть тело ответа, хотя партнёр ответил 404 или 500. Разбираю три уровня результата: транспорт, HTTP и контракт полезной нагрузки.',
readingMinutes: 10,
contentHtml: [
paragraph('После ночной выгрузки в логе стоит «запрос выполнен», потому что <code>curl_exec()</code> вернул строку. Утром выясняется, что строкой была HTML-страница с 403, а заказы не дошли. Ошибка в проверке не синтаксическая: код спросил cURL только о доставке ответа, а бизнес-код сделал вывод о результате всей операции. Разберём один вопрос: какой минимальный набор проверок отличает сетевой сбой, HTTP-отказ и рабочий ответ партнёра?'),
heading('У одного вызова три разных результата'),
paragraph('При включённом <code>CURLOPT_RETURNTRANSFER</code> функция <code>curl_exec()</code> возвращает тело ответа при успехе cURL и <code>false</code> при его ошибке. Проверять результат надо строгим сравнением: непустое тело может быть строкой <code>&quot;0&quot;</code>, которая в обычном условии ведёт себя как ложь. Главное здесь другое: статус 404 или 500 сам по себе не считается ошибкой cURL. Документация прямо предлагает читать HTTP-статус через <code>curl_getinfo()</code>.'),
paragraph('Отсюда порядок проверки. Сначала узнаём, состоялась ли передача: <code>$body === false</code>, <code>curl_errno()</code> и <code>curl_error()</code>. Затем читаем <code>http_code</code>, тип содержимого и время из <code>curl_getinfo()</code>. Только после этого разбираем тело как JSON или иной формат, который обещан договором с партнёром. Если смешать уровни, журнал начинает сообщать «ошибка API» и для DNS, и для 401, и для сломанного JSON.'),
figure('/assets/editorial/2018/curl-outcome-classifier.svg', 'Диаграмма классификации ответа cURL: false ведёт к транспортной ошибке; строка проверяется по HTTP-коду, затем по контракту тела', 'Положительный результат cURL означает, что библиотека получила ответ. Он ещё не означает, что HTTP-запрос и бизнес-операция завершились успешно.'),
heading('Что сохранять для каждого уровня'),
dataTable(
['Наблюдение', 'Класс сбоя', 'Что записать в журнал', 'Следующее действие'],
[
['<code>$body === false</code>', 'Транспорт или TLS', '<code>curl_errno</code>, <code>curl_error</code>, URL без секрета, время', 'Проверить DNS, сертификат, таймаут и доступность хоста'],
['Есть тело, <code>http_code</code> 401 или 403', 'Авторизация или права', 'HTTP-код, операция, внешний ID, request ID', 'Проверить учётные данные и область доступа; не печатать токен'],
['Есть тело, <code>http_code</code> 404', 'Адрес или версия API', 'HTTP-код и маршрут без query-параметров', 'Сверить путь, метод и версию endpoint'],
['Есть тело, <code>http_code</code> 500', 'Ошибка удалённой стороны', 'HTTP-код, request ID, первые безопасные признаки ответа', 'Передать партнёру ID запроса и время, не повторять запись вслепую'],
['2xx и ожидаемое тело', 'Транспорт и HTTP прошли', 'Код, размер и время ответа', 'Проверить обязательные поля тела перед изменением локальных данных'],
],
),
heading('Клиент, который не прячет уровень ошибки'),
paragraph('В примере ниже нет общего «интеграционного клиента». Нужна маленькая функция, которую легко вызвать в изолированном скрипте и легко покрыть разными ответами. Время соединения и общий таймаут здесь проектные: их надо выбирать под договорённость с конкретным сервисом, а не переносить числа из чужого кода.'),
codeBlock(String.raw`
<?php
function requestPartner($url, $requestId)
{
$handle = curl_init($url);
curl_setopt_array($handle, array(
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => array(
'Accept: application/json',
'X-Request-Id: ' . $requestId,
),
));
$body = curl_exec($handle);
$curlErrno = curl_errno($handle);
$curlError = curl_error($handle);
$info = curl_getinfo($handle);
curl_close($handle);
if ($body === false) {
throw new RuntimeException(json_encode(array(
'kind' => 'transport_error',
'request_id' => $requestId,
'curl_errno' => $curlErrno,
'curl_error' => $curlError,
'total_time' => $info['total_time'],
)));
}
$status = (int) $info['http_code'];
if ($status < 200 || $status >= 300) {
throw new RuntimeException(json_encode(array(
'kind' => 'http_error',
'request_id' => $requestId,
'http_code' => $status,
'content_type' => $info['content_type'],
'body_bytes' => strlen($body),
'total_time' => $info['total_time'],
)));
}
return array(
'body' => $body,
'content_type' => $info['content_type'],
'http_code' => $status,
'total_time' => $info['total_time'],
);
}
`),
paragraph('Функция намеренно не пишет URL целиком. Query-параметры часто содержат ключи, подписи или персональные идентификаторы. Если адрес нужен для расследования, лучше сохранить имя интеграции и заранее нормализованный путь. Тело ответа тоже не стоит бездумно добавлять к исключению: для первичного поиска достаточно размера, типа содержимого и ID операции; безопасный фрагмент можно сохранить отдельно в тестовой среде.'),
heading('Почему 2xx — ещё не результат операции'),
paragraph('HTTP-код описывает ответ сервера на протокольном уровне. Он не может за нас подтвердить, что партнёр принял заказ в нужном виде. Один API возвращает <code>{&quot;id&quot;:&quot;A-17&quot;}</code>, другой — <code>{&quot;accepted&quot;:true}</code>, третий ставит задачу в очередь. Поэтому после 2xx должна быть проверка конкретного поля, которое выбрано в контракте. Разбор JSON и формы ответа — отдельная граница; её нельзя заменять условием <code>if ($body)</code>.'),
paragraph('Это же объясняет, почему автоматический повтор записи нельзя включать как реакцию на любой сбой. При таймауте неизвестно, дошёл ли запрос до партнёра. Если операция создаёт заказ, второй POST может создать дубликат. Повтор становится безопасным только когда протокол даёт ключ идемпотентности, внешний ID или отдельный способ узнать результат первой попытки. Пока такого условия нет, в журнале должна появиться операция для разбора, а не второй запрос в фоне.'),
heading('Воспроизводимая матрица проверки'),
paragraph('Для проверки не нужен настоящий партнёр. Достаточно небольшого тестового endpoint, который по параметру возвращает 200 с JSON, 401, 500 и закрывает соединение. Важно сравнивать не только текст исключения, но и поля записи: у каждого сценария должен быть свой <code>kind</code>. Тогда мониторинг может отдельно считать транспортные сбои и ответы 5xx.'),
orderedList([
'Включить <code>CURLOPT_RETURNTRANSFER</code> и заменить все проверки <code>if (!$body)</code> на строгое <code>$body === false</code>.',
'Сразу после <code>curl_exec()</code> собрать <code>curl_errno</code>, <code>curl_error</code> и <code>curl_getinfo</code>, пока handle не закрыт.',
'Прогнать endpoint с недоступным адресом и проверить ветку <code>transport_error</code> с ненулевым кодом cURL.',
'Прогнать 401, 404 и 500; у них должна сработать ветка <code>http_error</code>, а не транспортная ошибка.',
'Прогнать 200 с корректным, но неожиданным телом и убедиться, что следующий слой контракта его не принимает автоматически.',
]),
heading('Границы примера'),
paragraph('Пример не задаёт универсальные таймауты и не обещает повтор запросов. Он не проверяет сертификаты вручную и не отключает TLS-проверку: если есть ошибка сертификата, её следует увидеть как транспортную причину и исправить настройку окружения. Он также не заменяет лимиты на размер ответа и контроль метода HTTP — эти правила зависят от конкретного API.'),
paragraph('Но даже такая небольшая развилка меняет качество диагностики. Вместо одного сообщения «интеграция не работает» появляются три проверяемых факта: передача не состоялась, удалённый сервер ответил не тем статусом или статус нормальный, но тело не прошло контракт. Дальше можно обсуждать решение с человеком, который отвечает именно за этот слой.'),
heading('Проверяемые источники'),
sourceList([phpCurlExec, phpCurlInfo, phpCurlErrno, httpSemantics]),
].join('\n'),
};
const fieldArticle = {
slug: 'editorial-2018-02-field-php-diagnostics',
title: 'PHP. Как отличить битый JSON от корректного null в ответе API',
categories: ['PHP', 'JSON', 'Интеграции'],
cover: '/assets/editorial/2018/json-payload-diagnostic.svg',
excerpt: 'Проверка if (!$data) смешивает пустой массив, false, null и ошибку декодирования. Собираем короткий разбор JSON для PHP 7.1 с проверкой json_last_error и контракта ответа.',
readingMinutes: 9,
contentHtml: [
paragraph('В обработчике ответа часто встречается одна строка: <code>if (!$data) { throw new Exception(&quot;bad response&quot;); }</code>. После неё невозможно понять, что случилось: партнёр вернул пустой список, честное <code>null</code>, число <code>0</code> или HTML вместо JSON. Ниже я оставляю пример в рамках PHP 7.1: в этой версии ещё нет <code>JSON_THROW_ON_ERROR</code>, поэтому после <code>json_decode()</code> нужно явно проверить состояние декодера.'),
paragraph('Главный вопрос здесь узкий: как отделить ошибку разбора JSON от корректного JSON, который не соответствует нашему договору? Ответ состоит из двух проверок подряд. Сначала сразу читаем <code>json_last_error()</code>. Только если там <code>JSON_ERROR_NONE</code>, проверяем тип и обязательные поля ответа.'),
heading('Почему null не доказывает ошибку'),
paragraph('По RFC 8259 JSON-текстом может быть не только объект или массив: допустимы также строка, число, <code>false</code>, <code>true</code> и <code>null</code>. PHP отражает это напрямую: <code>json_decode(&quot;null&quot;)</code> возвращает <code>null</code>, но <code>null</code> возвращается и когда строку нельзя декодировать. Одна проверка на значение не различает эти случаи.'),
paragraph('То же происходит с пустыми коллекциями. После <code>json_decode(&quot;[]&quot;, true)</code> получится пустой массив, который в PHP является ложным в условии. Это может быть правильный ответ поиска: товаров нет. Но тот же <code>if (!$data)</code> назовёт его «битым JSON». Сначала нужно проверить синтаксис, затем форму данных, и только потом решать, допустим ли пустой результат для данной операции.'),
figure('/assets/editorial/2018/json-payload-diagnostic.svg', 'Схема диагностики JSON: сырой ответ сначала проходит json_decode и json_last_error, затем проверку типа и обязательных полей контракта', 'Ошибка декодирования и нарушение контракта — разные события. У них разные владельцы и разные действия.'),
heading('Короткая таблица, которую стоит держать рядом с кодом'),
dataTable(
['Сырой ответ', 'Результат json_decode(..., true)', 'json_last_error', 'Что это значит для клиента'],
[
['<code>{&quot;order_id&quot;:&quot;A-17&quot;}</code>', 'ассоциативный массив', '<code>JSON_ERROR_NONE</code>', 'Проверить поле <code>order_id</code> и принять ответ'],
['<code>[]</code>', 'пустой массив', '<code>JSON_ERROR_NONE</code>', 'Корректный JSON; допустимость зависит от операции'],
['<code>null</code>', '<code>null</code>', '<code>JSON_ERROR_NONE</code>', 'Корректный JSON, но не тот тип, который ждёт данный endpoint'],
['<code>false</code> или <code>0</code>', '<code>false</code> или <code>0</code>', '<code>JSON_ERROR_NONE</code>', 'Корректный JSON; проверка на «ложь» здесь ошибочна'],
['<code>&lt;html&gt;503&lt;/html&gt;</code>', 'обычно <code>null</code>', '<code>JSON_ERROR_SYNTAX</code>', 'Неверный формат ответа; сохранить безопасный диагностический контекст'],
],
),
heading('Пример для PHP 7.1'),
paragraph('Функция ниже принимает уже полученное тело только после проверки HTTP-статуса. Она не пытается угадать, где возникли данные: транспорт и HTTP должны быть разобраны раньше. Здесь контракт намеренно маленький: мы ждём объект с непустым строковым <code>order_id</code>. В другом API это может быть список, поле <code>accepted</code> или код задачи — меняется проверка контракта, но не порядок диагностики.'),
codeBlock(String.raw`
<?php
function logPayloadProblem(array $record)
{
error_log(json_encode($record, JSON_UNESCAPED_UNICODE));
}
function rejectPayloadContract($reason, $requestId, $body)
{
logPayloadProblem(array(
'kind' => 'contract_error',
'reason' => $reason,
'request_id' => $requestId,
'body_bytes' => strlen($body),
'body_sha256' => hash('sha256', $body),
));
throw new UnexpectedValueException($reason);
}
function decodeCreatedOrder($body, $requestId)
{
if ($body === '') {
rejectPayloadContract('Partner returned an empty body', $requestId, $body);
}
$data = json_decode($body, true);
$jsonError = json_last_error();
if ($jsonError !== JSON_ERROR_NONE) {
logPayloadProblem(array(
'kind' => 'json_decode_error',
'request_id' => $requestId,
'json_error' => $jsonError,
'body_bytes' => strlen($body),
'body_sha256' => hash('sha256', $body),
));
throw new UnexpectedValueException('Partner response is not valid JSON');
}
if (!is_array($data)) {
rejectPayloadContract(
'Partner returned valid JSON, but not an object',
$requestId,
$body
);
}
if (
!array_key_exists('order_id', $data)
|| !is_string($data['order_id'])
|| $data['order_id'] === ''
) {
rejectPayloadContract(
'Partner JSON has no non-empty order_id',
$requestId,
$body
);
}
return $data;
}
`),
paragraph('Значение <code>json_last_error()</code> читается сразу после <code>json_decode()</code>. Это состояние относится к последней операции JSON, поэтому его легко затереть следующим <code>json_encode()</code> или повторным разбором. В примере в журнал попадают код ошибки, размер тела и хеш. Хеш позволяет сравнить два ответа, не печатая сам ответ в общий журнал. Если отладка требует фрагмент тела, её лучше проводить в ограниченной тестовой среде с маскированием данных.'),
heading('Не путать формат с договором'),
paragraph('Предположим, партнёр ответил <code>[]</code>. С точки зрения JSON всё в порядке. Для запроса «найди заказы за час» это может быть нормальный нулевой результат. Для запроса «создай заказ» пустой массив не годится, потому что договор ожидает идентификатор. Это уже не ошибка декодера и не повод говорить, что «API вернул битый JSON». Это нарушение контракта полезной нагрузки.'),
paragraph('Такая формулировка помогает и при разговоре с партнёром. Вместо расплывчатого «не распарсили ответ» можно передать факт: HTTP-статус был 200, JSON синтаксически корректен, но поле <code>order_id</code> отсутствует или имеет другой тип. Это сообщение можно проверить на их стороне и закрепить в документации API.'),
heading('Проверка на четырёх маленьких ответах'),
paragraph('Тест не обязан ходить в сеть. Достаточно передать функции строки и сравнить исключение или результат. Важно держать рядом успешный пустой сценарий только для той операции, где пустота допустима: иначе тест сам начнёт размывать договор.'),
orderedList([
'Передать <code>{&quot;order_id&quot;:&quot;A-17&quot;}</code> и проверить, что функция вернула массив с идентификатором.',
'Передать <code>&lt;html&gt;maintenance&lt;/html&gt;</code>; ожидается ветка <code>json_decode_error</code> с кодом <code>JSON_ERROR_SYNTAX</code>.',
'Передать <code>null</code>; <code>json_last_error()</code> должен показать успех разбора, а функция должна отклонить неподходящий тип.',
'Передать <code>[]</code>; разбор успешен, но контракт создания заказа должен отклонить отсутствие <code>order_id</code>.',
'Отдельно проверить поиск или список, где <code>[]</code> является валидным результатом, чтобы не переносить правила одной операции на другую.',
]),
heading('Версия PHP и ограничения'),
paragraph('В PHP 7.3 появился флаг <code>JSON_THROW_ON_ERROR</code>. В этом примере я намеренно остаюсь на PHP 7.1, поэтому проверка <code>json_last_error()</code> — нормальный механизм для выбранной версии, а не обходной путь. Если проект уже обновлён, исключения могут сделать код компактнее, но проверка формы ответа всё равно остаётся.'),
paragraph('Декодер ожидает строку в UTF-8. Ошибка <code>JSON_ERROR_UTF8</code> говорит о проблеме кодировки, но не объясняет, на каком именно участке она появилась. Для такого случая нужны метрики и безопасный способ сравнить исходные ответы, а не принудительное перекодирование всей строки без понимания источника. RFC 8259 рекомендует уникальные имена в объекте; при повторе разные реализации могут вести себя по-разному. Если поле критично, договор API должен фиксировать его единственность.'),
heading('Что оставить после исправления'),
paragraph('После этой доработки в клиенте остаются два разных события: <code>json_decode_error</code> для невалидного формата и <code>contract_error</code> для валидного, но неожиданного объекта. У них разные причины, разная срочность и разные адресаты. А условие <code>if (!$data)</code> исчезает: оно не способно сказать, что именно произошло.'),
heading('Проверяемые источники'),
sourceList([phpJsonDecode, phpJsonLastError, jsonRfc]),
].join('\n'),
};
const revisions = [practiceArticle, mechanismArticle, fieldArticle];
function plainText(content) {
return content
.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*$/, '')
.replace(/<[^>]+>/g, ' ')
.replace(/&(?:quot|amp|lt|gt|#039);/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function assertRevisionQuality(revision) {
const body = plainText(revision.contentHtml);
const issues = [];
if (body.length < 5000 || body.length > 15000) {
issues.push('основной текст: ' + body.length + ' знаков');
}
if ((revision.contentHtml.match(/<figure>/g) || []).length !== 1) {
issues.push('нужен ровно один главный рисунок');
}
if (!revision.contentHtml.includes('<table>')) issues.push('нет таблицы');
if (!revision.contentHtml.includes('<pre><code>')) issues.push('нет примера кода');
if (!revision.contentHtml.includes('<ol>')) issues.push('нет последовательности действий');
if (!revision.contentHtml.includes('<h2>Проверяемые источники</h2>')) {
issues.push('нет раздела с источниками');
}
if ((revision.contentHtml.match(/<a href="https?:\/\//g) || []).length < 2) {
issues.push('меньше двух источников');
}
if (revision.contentHtml.includes('undefined') || revision.contentHtml.includes('[object Object]')) {
issues.push('в тексте есть след генерации');
}
if (issues.length > 0) {
throw new Error(revision.slug + ': ' + issues.join('; '));
}
}
for (const revision of revisions) {
assertRevisionQuality(revision);
}
if (!process.argv.includes('--print-revisions')) {
throw new Error('Usage: node scripts/upgrade-2018-02.mjs --print-revisions');
}
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
+412
View File
@@ -0,0 +1,412 @@
const escapeHtml = (value) => String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
const paragraph = (content) => '<p>' + content + '</p>';
const heading = (content) => '<h2>' + content + '</h2>';
const codeBlock = (source) => '<pre><code>' + escapeHtml(source.trim()) + '</code></pre>';
const figure = (src, alt, caption) => [
'<figure>',
'<img src="' + src + '" alt="' + alt + '" />',
'<figcaption>' + caption + '</figcaption>',
'</figure>',
].join('');
function dataTable(headers, rows) {
const head = headers.map((header) => '<th scope="col">' + header + '</th>').join('');
const body = rows.map((row) => (
'<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>'
)).join('');
return '<div class="table-scroll"><table><thead><tr>' + head
+ '</tr></thead><tbody>' + body + '</tbody></table></div>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function bulletList(items) {
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
}
function sourceList(sources) {
return '<ul>' + sources.map(({ title, url }) => (
'<li><a href="' + url + '" target="_blank" rel="noopener">' + title + '</a></li>'
)).join('') + '</ul>';
}
const phpUploadErrors = {
title: 'PHP Manual: коды ошибок загрузки',
url: 'https://www.php.net/manual/en/features.file-upload.errors.php',
};
const phpMoveUploadedFile = {
title: 'PHP Manual: move_uploaded_file',
url: 'https://www.php.net/manual/en/function.move-uploaded-file.php',
};
const phpFileinfo = {
title: 'PHP Manual: finfo_file',
url: 'https://www.php.net/manual/en/function.finfo-file.php',
};
const phpGetImageSize = {
title: 'PHP Manual: getimagesize и его ограничение как валидатора',
url: 'https://www.php.net/manual/en/function.getimagesize.php',
};
const phpHeader = {
title: 'PHP Manual: header',
url: 'https://www.php.net/manual/en/function.header.php',
};
const phpReadfile = {
title: 'PHP Manual: readfile',
url: 'https://www.php.net/manual/en/function.readfile.php',
};
const multipartRfc = {
title: 'RFC 7578: multipart/form-data',
url: 'https://www.rfc-editor.org/rfc/rfc7578',
};
const contentDispositionRfc = {
title: 'RFC 6266: Content-Disposition в HTTP',
url: 'https://www.rfc-editor.org/rfc/rfc6266',
};
const owaspUpload = {
title: 'OWASP File Upload Cheat Sheet',
url: 'https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html',
};
const practiceArticle = {
slug: 'editorial-2018-03-practice-safe-uploads',
title: 'PHP. Безопасная загрузка аватара: минимальный маршрут без доверия к имени файла',
categories: ['PHP', 'Безопасность'],
cover: '/assets/editorial/2018/php-upload-avatar-contract.svg',
excerpt: 'Собираем маленький обработчик для JPEG и PNG: проверяем доставку, размер и содержимое, сохраняем под своим именем и не отдаём путь из веб-корня.',
readingMinutes: 9,
contentHtml: [
paragraph('Загрузка аватара обычно начинается с одного поля формы и вызова <code>move_uploaded_file</code>. Ошибка становится заметна позже: каталог <code>uploads</code> оказывается доступен из веб-корня, имя файла совпадает с уже существующим, а проверка сводится к <code>.jpg</code>. В итоге сервер принимает решение по данным, которые прислал браузер. Давайте соберём минимальный маршрут, где каждое такое решение видно в коде.'),
paragraph('Вопрос этой заметки один: <strong>как принять только JPEG и PNG для аватара, не превращая имя и MIME-тип из формы в правило безопасности?</strong> Пример рассчитан на PHP 7.2. Он не заменяет антивирус и не умеет обрабатывать документы; его задача уже — дать узкий и проверяемый вход для изображения.'),
heading('Сначала договоримся о результате'),
paragraph('Форма передаёт один файл <code>avatar</code>. Мы принимаем не более 2 МБ, только <code>image/jpeg</code> и <code>image/png</code>, а затем ограничиваем ширину и высоту. В базе или профиле хранится ключ, который придумало приложение, например <code>7f4a...c2.png</code>. Исходное имя можно показать пользователю после отдельной обработки, но оно не участвует в пути на диске.'),
figure(
'/assets/editorial/2018/php-upload-avatar-contract.svg',
'Путь файла аватара: браузер передаёт multipart-часть, PHP создаёт временный файл, код проверяет его и переносит в закрытое хранилище под сгенерированным ключом.',
'Проверки идут до переноса. После переноса остаётся ключ приложения, а не имя из формы.',
),
dataTable(
['Проверка', 'Что она отвечает', 'Что делаем при отказе'],
[
['<code>UPLOAD_ERR_OK</code>', 'PHP полностью принял часть запроса', 'Не читаем временный путь, показываем понятную ошибку загрузки'],
['Лимит 2 МБ', 'Файл укладывается в договор аватара', 'Не переносим файл и не пытаемся уменьшать его вслепую'],
['<code>finfo_file</code>', 'Какой MIME-тип определён по временному файлу', 'Отклоняем тип, которого нет в белом списке'],
['Размеры изображения', 'Подходит ли картинка для интерфейса', 'Отклоняем слишком маленькое или слишком большое изображение'],
['Сгенерированный ключ', 'Куда именно будет записан файл', 'Никогда не составляем путь из исходного имени'],
],
),
heading('Обработчик без скрытого шага'),
paragraph('Проверка <code>$_FILES["avatar"]["error"]</code> должна идти первой. PHP кладёт в это поле код доставки: если загрузка не завершилась, временный файл нельзя считать нормальным входом. Затем я сравниваю размер и запускаю Fileinfo для временного файла. Поле <code>type</code> из <code>$_FILES</code> здесь намеренно не используется: его прислал клиент.'),
codeBlock(String.raw`
<?php
function storeAvatar(array $file, string $privateDir): array
{
if (!isset($file['error'], $file['tmp_name'], $file['size'])) {
throw new RuntimeException('Поле avatar передано в неверном формате');
}
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException('PHP не принял файл: код ' . $file['error']);
}
$maxBytes = 2 * 1024 * 1024;
if ((int)$file['size'] > $maxBytes) {
throw new RuntimeException('Аватар больше 2 МБ');
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo === false) {
throw new RuntimeException('Расширение Fileinfo недоступно');
}
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
$allowed = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
];
if (!is_string($mime) || !isset($allowed[$mime])) {
throw new RuntimeException('Нужен JPEG или PNG');
}
$size = getimagesize($file['tmp_name']);
if ($size === false) {
throw new RuntimeException('Не удалось прочитать размеры изображения');
}
list($width, $height) = $size;
if ($width < 64 || $height < 64 || $width > 3000 || $height > 3000) {
throw new RuntimeException('Размеры изображения вне допустимого диапазона');
}
$storageKey = bin2hex(random_bytes(16)) . '.' . $allowed[$mime];
$target = rtrim($privateDir, DIRECTORY_SEPARATOR)
. DIRECTORY_SEPARATOR . $storageKey;
if (!move_uploaded_file($file['tmp_name'], $target)) {
throw new RuntimeException('Не удалось сохранить аватар');
}
return [
'storageKey' => $storageKey,
'mime' => $mime,
'width' => $width,
'height' => $height,
];
}
`),
heading('Почему порядок проверок важнее набора функций'),
paragraph('У <code>move_uploaded_file</code> есть собственная проверка: исходный путь должен быть файлом, пришедшим через HTTP POST. Это полезная граница, но она не говорит, что перед нами именно изображение для аватара. Поэтому перенос стоит последним. До него мы принимаем решение по коду ошибки, размеру, серверному определению MIME-типа и проектным размерам.'),
paragraph('Вызов <code>getimagesize</code> нужен здесь только для размеров. В документации PHP отдельно сказано не использовать его как проверку того, что файл является корректным изображением; для определения типа подходит Fileinfo. Это хороший пример узкой ответственности: одна функция отвечает за признаки файла, другая — за параметры картинки, а не за всё сразу.'),
heading('Минимальная форма и проверка руками'),
codeBlock(String.raw`
<form method="post" enctype="multipart/form-data" action="/profile/avatar.php">
<input type="file" name="avatar" accept="image/jpeg,image/png" required>
<button type="submit">Сохранить аватар</button>
</form>
`),
paragraph('Атрибут <code>accept</code> помогает интерфейсу, но не заменяет серверную проверку. После подключения обработчика я бы не ограничивался одним удачным JPEG. Нужны четыре коротких сценария: нормальный JPEG, PNG, текстовый файл с расширением <code>.jpg</code> и картинка больше лимита. Для каждого фиксируем HTTP-ответ, наличие или отсутствие файла в хранилище и запись ключа в профиле.'),
heading('Порядок запуска'),
orderedList([
'Создать отдельный каталог для файлов за пределами веб-корня и дать PHP права только на нужную операцию записи.',
'Подключить форму с <code>multipart/form-data</code> и передать <code>$_FILES["avatar"]</code> в функцию.',
'После успешного вызова сохранить только <code>storageKey</code>, MIME-тип и размеры рядом с пользователем.',
'Проверить отрицательные сценарии: при любой ошибке ни файл, ни ссылка на него не должны появиться в профиле.',
'Отдельно решить, как читать аватар пользователю: прямой URL подходит лишь для действительно публичной картинки.',
]),
heading('Граница этого примера'),
paragraph('Код не сканирует файл на вредоносное содержимое и не защищает форму от CSRF. Он также не делает миниатюры: если добавить внешний конвертер, появится отдельная граница с лимитами, тайм-аутами и обновлением библиотек. Для аватаров я бы сначала запустил ровно этот узкий маршрут, измерил ошибки и только потом усложнял обработку.'),
heading('Проверяемые источники'),
sourceList([phpUploadErrors, phpMoveUploadedFile, phpFileinfo, phpGetImageSize, owaspUpload]),
].join('\n'),
};
const mechanismArticle = {
slug: 'editorial-2018-03-mechanism-safe-uploads',
title: 'PHP. Почему расширение и Content-Type не отвечают на вопрос «что за файл?»',
categories: ['PHP', 'Безопасность'],
cover: '/assets/editorial/2018/php-upload-trust-signals.svg',
excerpt: 'Разбираем, какие сведения о загрузке пришли от клиента, какие получил PHP и где серверу действительно стоит принимать решение о допустимом файле.',
readingMinutes: 9,
contentHtml: [
paragraph('Симптом: обработчик пропускает файл с <code>type=image/jpeg</code>, хотя Fileinfo для временного файла определяет другой тип. Цена ошибки — приложение сохраняет и позднее выдаёт контент, которого этот маршрут не должен был принимать. Самая коварная строка в обработчике загрузки выглядит безобидно: <code>if ($file["type"] === "image/jpeg")</code>. Она работает с обычным браузером и ломает модель в тот момент, когда запрос собран не браузером. В multipart-форме имя файла и Content-Type — часть сообщения клиента. Сервер получает эти поля, но не обязан считать их доказательством содержимого.'),
paragraph('Главный вопрос статьи: <strong>какие признаки файла можно использовать для какой проверки?</strong> Ответ не сводится к одной «правильной» функции. У доставки, типа, размеров и имени разные источники, поэтому их нельзя склеивать в одну проверку с красивым названием <code>validateUpload()</code>.'),
heading('Где заканчиваются сведения клиента'),
paragraph('RFC 7578 описывает <code>multipart/form-data</code>: файл приходит отдельной частью с заголовками, среди которых может быть Content-Type. Это формат передачи, а не подпись под содержимым. PHP раскладывает результат в <code>$_FILES</code>; там есть исходное имя, клиентский тип, размер, временный путь и код ошибки. У каждого поля своя ценность.'),
figure(
'/assets/editorial/2018/php-upload-trust-signals.svg',
'Схема границ доверия: имя и Content-Type идут от клиента, PHP сообщает результат доставки, Fileinfo изучает временный файл, а приложение применяет собственный белый список.',
'Клиентские метаданные полезны для интерфейса и диагностики. Решение о допуске принимает приложение после проверки временного файла.',
),
dataTable(
['Сигнал', 'Откуда он взялся', 'Правильное применение'],
[
['<code>$file["name"]</code>', 'Имя, переданное клиентом', 'Показать как подпись после экранирования; не строить из него путь'],
['Расширение', 'Часть клиентского имени', 'Использовать как удобный фильтр интерфейса, но не как доказательство типа'],
['<code>$file["type"]</code>', 'Content-Type multipart-части', 'Сохранить в отладочном журнале, но не использовать для допуска'],
['<code>$file["error"]</code>', 'Результат, который сообщил PHP', 'Продолжать только при <code>UPLOAD_ERR_OK</code>'],
['<code>finfo_file()</code>', 'Анализ временного файла на сервере', 'Сравнить с точным белым списком допустимых MIME-типов'],
['<code>getimagesize()</code>', 'Попытка прочитать параметры изображения', 'Проверить размеры после Fileinfo, но не считать это проверкой безопасности'],
],
),
heading('Короткий опыт на локальной машине'),
paragraph('Ниже не нужен вредоносный файл. Достаточно обычного текста и вручную заданного Content-Type. Поднимите встроенный сервер PHP в каталоге с <code>inspect.php</code>, отправьте файл через <code>curl</code> и посмотрите на два значения. Конкретный MIME-результат Fileinfo может зависеть от его базы, но он определяется по временному файлу, а не по параметру <code>type=image/jpeg</code> в команде.'),
codeBlock(String.raw`
<?php
// inspect.php
$file = $_FILES['avatar'] ?? [];
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
http_response_code(400);
exit('Файл не получен');
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$detected = $finfo ? finfo_file($finfo, $file['tmp_name']) : false;
if ($finfo) {
finfo_close($finfo);
}
header('Content-Type: text/plain; charset=utf-8');
echo 'type from request: ' . ($file['type'] ?? '-') . PHP_EOL;
echo 'type from Fileinfo: ' . ($detected ?: '-') . PHP_EOL;
`),
codeBlock(String.raw`
printf '<html>это не фотография</html>' > /tmp/not-an-image.txt
php -S 127.0.0.1:8080
curl -F 'avatar=@/tmp/not-an-image.txt;type=image/jpeg' \
http://127.0.0.1:8080/inspect.php
`),
paragraph('Такой опыт не доказывает, что Fileinfo распознает все форматы без ошибок. Он доказывает более скромную вещь: строка <code>$file["type"]</code> описывает заявление отправителя, а не результат серверной проверки. Этого уже достаточно, чтобы убрать её из условия допуска.'),
heading('Функция, которая возвращает только полезный контракт'),
paragraph('После опыта можно свести проверку к небольшому контракту. Функция ниже не переносит файл и не создаёт запись в базе. Она отвечает только на вопрос, можно ли передать временный файл следующему шагу, и возвращает значение, которое тот шаг действительно использует.'),
codeBlock(String.raw`
<?php
function inspectImageUpload(array $file): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('Загрузка не завершилась');
}
if (!isset($file['tmp_name'], $file['size']) || (int)$file['size'] > 2097152) {
throw new RuntimeException('Размер файла недопустим');
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo === false) {
throw new RuntimeException('Fileinfo недоступен');
}
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
$extensions = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
];
if (!is_string($mime) || !isset($extensions[$mime])) {
throw new RuntimeException('Допустимы только JPEG и PNG');
}
return [
'temporaryPath' => $file['tmp_name'],
'mime' => $mime,
'extension' => $extensions[$mime],
'bytes' => (int)$file['size'],
];
}
`),
heading('Почему это не «одна проверка вместо всех»'),
paragraph('Fileinfo отвечает на вопрос о типе, но не о праве пользователя загружать файл, не о свободном месте и не о том, можно ли безопасно разбирать этот формат дополнительной библиотекой. В нашем случае разрешены только две картинки, поэтому белый список короткий. Если продукту нужны PDF, архивы и таблицы, лучше не расширять тот же массив до десятка значений, а сделать отдельные маршруты с отдельными лимитами и правилами выдачи.'),
paragraph('Расширение всё ещё может быть полезным для интерфейса: по нему браузер открывает фильтр выбора, а пользователь понимает, какой файл выбрал. Но серверный ключ и расширение результата лучше строить из решения приложения: Fileinfo вернул <code>image/png</code> — приложение выбирает <code>.png</code>. Так имя не способно незаметно поменять путь или ожидаемый обработчик.'),
heading('Последовательность проверки'),
orderedList([
'Проверить код <code>UPLOAD_ERR_*</code> и остановиться до чтения временного файла при любой ошибке.',
'Проверить размер, потому что допустимый тип не отменяет ограничение на место и время обработки.',
'Определить MIME-тип через Fileinfo и сравнить его с белым списком именно этого сценария.',
'Если нужны размеры, прочитать их после проверки типа и трактовать как требование интерфейса, а не как сертификат безопасности.',
'Передать следующему слою только сгенерированный ключ, серверный MIME-тип и нужные метаданные; клиентское имя оставить за пределами файлового пути.',
]),
heading('Ограничения'),
paragraph('Пример не является антивирусом и не делает опасный формат безопасным. Он также не ограничивает размер всего HTTP-запроса на уровне веб-сервера и PHP-конфигурации. Это нужно проверять отдельно: прикладной лимит защищает логику, а ограничения окружения — сам приём запроса. Если затем файл отдаётся другим пользователям, появляется ещё один самостоятельный вопрос: кто и по какому маршруту его читает.'),
heading('Проверяемые источники'),
sourceList([multipartRfc, phpUploadErrors, phpFileinfo, phpGetImageSize, owaspUpload]),
].join('\n'),
};
const fieldArticle = {
slug: 'editorial-2018-03-field-safe-uploads',
title: 'PHP. Как отдать приватный файл владельцу и не сделать uploads публичной папкой',
categories: ['PHP', 'Безопасность'],
cover: '/assets/editorial/2018/php-private-download-flow.svg',
excerpt: 'Разбираем контролируемую выдачу документа: путь хранится вне веб-корня, доступ проверяется по записи в базе, а браузер получает содержимое только после авторизации.',
readingMinutes: 9,
contentHtml: [
paragraph('Симптом: личный документ открывается по прямому URL из <code>/uploads</code> без повторной проверки пользователя. Цена ошибки — ссылка становится фактическим правом доступа и может раскрыть файл не тому человеку. Файл можно проверить при загрузке и всё равно потерять контроль над ним при выдаче. Типичный путь выглядит так: пользователь прикрепил документ, приложение положило его в <code>/uploads</code>, а ссылка стала чем-то вроде <code>/uploads/ivan-passport.pdf</code>. Теперь имя файла одновременно является адресом и фактически проверкой доступа. Для личного документа это слишком много ответственности у одной строки.'),
paragraph('Здесь разбираю один вопрос: <strong>как дать владельцу скачать приватный PDF, если сам файл лежит вне веб-корня?</strong> Это небольшой PHP 7.2-пример для внутренних документов. Он не пытается строить файловый сервис, а показывает границу: маршрут приложения решает доступ, файловая система хранит байты.'),
heading('У файла должны быть две разные сущности'),
paragraph('Пользовательский документ имеет понятное имя — «счёт за март.pdf». Хранилищу оно не нужно. Ему нужен стабильный ключ, который создаёт приложение: например, 32 шестнадцатеричных символа с расширением <code>.pdf</code>. В базе связываем ключ с владельцем и типом. HTTP-маршрут принимает только числовой ID записи, ищет её вместе с владельцем и уже потом открывает путь.'),
figure(
'/assets/editorial/2018/php-private-download-flow.svg',
'Схема приватной выдачи: запрос к маршруту проходит авторизацию, запись в базе связывает владельца с ключом, PHP читает файл из закрытого каталога и отправляет ответ.',
'Прямой путь к файлу не выдаётся браузеру. Авторизация остаётся до чтения с диска.',
),
dataTable(
['Слой', 'Что в нём храним', 'Чего в нём нет'],
[
['Таблица <code>documents</code>', '<code>id</code>, <code>owner_id</code>, <code>storage_key</code>, статус', 'Публичного URL и пути, собранного из имени пользователя'],
['Закрытый каталог', 'Файл по ключу, созданному приложением', 'Оригинального имени и логики авторизации'],
['Маршрут <code>/documents/{id}/download</code>', 'Проверку текущего пользователя и HTTP-ответ', 'Свободного параметра <code>path</code> из запроса'],
['Браузер', 'Содержимое файла после успешного ответа', 'Сведений о расположении файла на сервере'],
],
),
heading('Небольшой обработчик PDF'),
paragraph('Для ясности пример обслуживает только PDF. MIME-тип в ответе задан кодом, а не переписан из имени или запроса. Имя в <code>Content-Disposition</code> тоже фиксировано: задача заметки — доступ, а не универсальная передача пользовательских названий через заголовок. В реальном интерфейсе красивое имя можно хранить отдельно и добавлять в заголовок только после нормализации.'),
codeBlock(String.raw`
<?php
function sendPrivatePdf(PDO $pdo, int $documentId, int $currentUserId): void
{
$query = $pdo->prepare(
'SELECT storage_key
FROM documents
WHERE id = :id AND owner_id = :owner_id AND status = :status'
);
$query->execute([
':id' => $documentId,
':owner_id' => $currentUserId,
':status' => 'ready',
]);
$document = $query->fetch(PDO::FETCH_ASSOC);
if (!$document) {
http_response_code(404);
exit;
}
$key = (string)$document['storage_key'];
if (!preg_match('/\\A[a-f0-9]{32}\\.pdf\\z/', $key)) {
error_log('Некорректный ключ документа ' . $documentId);
http_response_code(404);
exit;
}
$path = '/var/app/private-uploads/' . $key;
if (!is_file($path)) {
error_log('Не найден файл для документа ' . $documentId);
http_response_code(404);
exit;
}
header('Content-Type: application/pdf');
header('Content-Disposition: attachment; filename="document.pdf"');
header('Content-Length: ' . filesize($path));
readfile($path);
exit;
}
`),
paragraph('SQL-запрос проверяет владельца вместе с ID документа. Поэтому путь на диске не зависит от значения из URL. Регулярное выражение кажется избыточным, но оно защищает код от испорченной записи в базе и фиксирует контракт ключа рядом с местом, где ключ превращается в путь. Если запись чужая или отсутствует, пример отвечает одинаковым <code>404</code>; это решение уменьшает различие ответов, но журналировать такие случаи всё равно полезно.'),
heading('Как воспроизвести проверку'),
paragraph('На тестовой базе достаточно двух пользователей: Анны и Бориса. Создаём запись документа Анны со статусом <code>ready</code> и кладём тестовый PDF с соответствующим ключом в закрытый каталог. Затем повторяем одни и те же действия из двух сессий. Здесь важен не красивый экран, а наблюдаемые HTTP-ответы и отсутствие прямой ссылки на каталог.'),
orderedList([
'Анна запрашивает <code>/documents/42/download</code>: получает <code>200</code>, заголовок <code>Content-Type: application/pdf</code> и байты тестового файла.',
'Борис запрашивает тот же URL: получает <code>404</code>, а тело файла не попадает в ответ.',
'Запрос к предполагаемому пути <code>/uploads/&lt;storage_key&gt;</code> не должен находить файл, потому что каталог не лежит в веб-корне.',
'Удаляем файл на диске при сохранённой записи: получаем <code>404</code> и запись в серверном журнале без абсолютного пути в ответе пользователю.',
'Пробуем передать в URL похожий ID или строку вместо числа: роутер должен отклонить запрос до вызова функции.',
]),
heading('Что будет, если оставить прямую ссылку'),
paragraph('Для публичной картинки прямой URL может быть нормальным контрактом. Для чека, договора или личного вложения он смешивает хранение с авторизацией: проверка пользователя происходит один раз при создании ссылки, а дальше файл живёт по адресу сам по себе. Закрытый каталог и маршрут не делают систему неуязвимой, зато возвращают проверку доступа в приложение, где есть пользователь, роль, статус документа и журнал.'),
heading('Ограничения этого решения'),
paragraph('У <code>readfile</code> простая задача — отдать содержимое файла в ответ. В примере нет поддержки диапазонов, кеширования, ограничения частоты загрузок и фоновой выдачи больших файлов. Для небольших PDF это хорошая стартовая точка. Для видео, больших архивов или заметного трафика потребуется передать доставку веб-серверу или файловому хранилищу, но проверку доступа и сопоставление ID с ключом нельзя потерять по дороге.'),
paragraph('Загрузка и выдача связаны, но не должны быть одной функцией. При загрузке приложение выбирает допустимый формат и ключ; при выдаче — проверяет владельца и формирует HTTP-ответ до любого вывода. PHP Manual отдельно напоминает, что <code>header()</code> вызывается до отправки тела ответа; поэтому в обработчике не должно быть случайного HTML или отладочного <code>echo</code> раньше заголовков.'),
heading('Проверяемые источники'),
sourceList([owaspUpload, phpHeader, phpReadfile, contentDispositionRfc]),
].join('\n'),
};
export const revisions = [practiceArticle, mechanismArticle, fieldArticle];
if (process.argv[1]?.endsWith('/upgrade-2018-03.mjs')) {
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
} else {
process.stderr.write('Usage: node scripts/upgrade-2018-03.mjs --print-revisions\n');
process.exitCode = 1;
}
}
+488
View File
@@ -0,0 +1,488 @@
import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const webRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const articlesPath = join(webRoot, 'data', 'articles.json');
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(lines) {
return '<pre><code>' + escapeHtml(lines.join('\n')) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function bulletList(items) {
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
}
function dataTable(headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function textFromHtml(html) {
return html
.replace(/<[^>]*>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replace(/\s+/g, ' ')
.trim();
}
const translit = {
title: 'Bitrix: CUtil::translit',
url: 'https://dev.1c-bitrix.ru/api_help/main/reference/cutil/translit.php',
note: 'параметры нормализации строки: регистр, замена пробелов и повторяющихся разделителей',
};
const addElement = {
title: 'Bitrix: CIBlockElement::Add',
url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/add.php?print=Y',
note: 'создание элемента, поле CODE, возвращаемый ID и LAST_ERROR при ошибке',
};
const getList = {
title: 'Bitrix: CIBlockElement::GetList',
url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/getlist.php?print=Y',
note: 'выборка элементов по фильтрам IBLOCK_ID, CODE, ACTIVE и с заданным порядком',
};
const updateElement = {
title: 'Bitrix: CIBlockElement::Update',
url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/update.php?print=Y',
note: 'изменение полей существующего элемента и результат операции',
};
const parseComponentPath = {
title: 'Bitrix: CComponentEngine::ParseComponentPath',
url: 'https://dev.1c-bitrix.ru/api_help/main/reference/ccomponentengine/parsecomponentpath.php',
note: 'разбор ЧПУ-пути по шаблонам и восстановление переменных компонента',
};
const makePathFromTemplate = {
title: 'Bitrix: CComponentEngine::MakePathFromTemplate',
url: 'https://dev.1c-bitrix.ru/api_help/main/reference/ccomponentengine/makepathfromtemplate.php',
note: 'подстановка значений массива в маркеры URL-шаблона',
};
const drafts = [
{
slug: 'editorial-2018-04-practice-bitrix-slugs',
title: 'Bitrix API. Символьный код: как не получить два одинаковых адреса',
categories: ['Bitrix', 'PHP', 'Практика'],
cover: '/assets/editorial/2018/bitrix-slug-build-2018.svg',
excerpt: 'Собираем символьный код элемента из имени, проверяем занятость в нужном инфоблоке и разбираем границу, за которой простой суффикс перестаёт быть защитой.',
readingMinutes: 10,
sources: [translit, getList, addElement],
bodyHtml: [
paragraph('Добавляем товар в Bitrix и берём <code>CODE</code> из названия. На тесте всё выглядит хорошо. Потом менеджер заводит «Кофе Classic 250 г» второй раз — с запятой или лишним пробелом. После транслитерации получается тот же адрес, а ссылка из каталога ведёт к записи, которую никто не собирался открывать. Главный вопрос этой заметки простой: как получить читаемый код и не принять совпадение за успех?'),
paragraph('Сначала важная оговорка. Транслитерация не выбирает свободный URL. Она преобразует строку по заданным правилам. Уникальность — уже правило конкретного инфоблока и конкретного способа создания элементов. Поэтому проверяем не «красиво ли выглядит код», а есть ли другой элемент с тем же значением там, где его будет искать каталог.'),
heading('Что даёт системный транслит'),
paragraph('В Bitrix для этой задачи есть <code>CUtil::translit</code>. Метод принимает строку, язык и набор параметров. В нём можно задать регистр, замену пробелов и прочих символов, ограничение длины, а также удаление повторяющихся замен. Для адреса каталога мне удобнее дефис и нижний регистр: в результате не приходится отдельно объяснять, почему одни карточки имеют подчёркивание, а другие — дефис.'),
paragraph('Но нормализация не делает два разных названия разными. «Кофе Classic 250 г», «Кофе Classic-250 г» и «Кофе Classic 250 г» вполне могут прийти к одному кандидату. Это не ошибка <code>CUtil::translit</code>. Функция честно выполнила свою работу: привела вход к одному виду. Сравнивать и разрешать конфликт должен вызывающий код.'),
figure('/assets/editorial/2018/bitrix-slug-build-2018.svg', 'Схема построения символьного кода: имя, транслитерация, проверка через GetList, суффикс или создание элемента', 'Транслит формирует кандидата. Решение о свободном коде появляется только после проверки в нужном инфоблоке.'),
heading('Минимальный контракт'),
paragraph('Для одного каталога достаточно договориться о нескольких вещах до написания функции. Они не привязаны к шаблону страницы и не требуют большой переделки. Зато по ним сразу видно, почему повторный импорт изменил адрес или почему карточка попала не в тот раздел.'),
dataTable(
['Шаг', 'Что считаем результатом', 'Что проверяем'],
[
['Имя', 'Есть непустое название', 'Не передаём в транслит пустую строку и не придумываем код из ID молча'],
['Нормализация', 'Один предсказуемый кандидат', 'Регистр, дефис, длина и повторяющиеся разделители заданы явно'],
['Поиск', 'Нет элемента с тем же CODE', 'Ищем внутри конкретного <code>IBLOCK_ID</code>, а не по всему сайту'],
['Сохранение', 'Метод Add вернул ID', 'При ошибке сохраняем <code>LAST_ERROR</code> и исходное имя'],
['Проверка ссылки', 'Каталог находит именно эту запись', 'Сверяем URL-шаблон и фильтр детального компонента'],
],
),
heading('Воспроизводимый пример'),
paragraph('Ниже функция для последовательного добавления из админки или небольшого импорта. Число 50 здесь не ограничение Bitrix, а мой предел для понятной ошибки: если за пятьдесят попыток не найден свободный вариант, лучше остановиться и посмотреть на входные данные. В реальном проекте ID инфоблока и правило суффикса стоит вынести в конфигурацию.'),
codeBlock([
'<?php',
'',
'function getFreeElementCode($iblockId, $name)',
'{',
' $base = CUtil::translit(trim($name), "ru", array(',
' "max_len" => 90,',
' "change_case" => "L",',
' "replace_space" => "-",',
' "replace_other" => "-",',
' "delete_repeat_replace" => true,',
' ));',
'',
' $base = trim($base, "-");',
' if ($base === "") {',
' throw new InvalidArgumentException("Не удалось получить CODE из NAME");',
' }',
'',
' for ($number = 1; $number <= 50; $number++) {',
' $candidate = $number === 1 ? $base : $base . "-" . $number;',
' $result = CIBlockElement::GetList(',
' array(),',
' array("IBLOCK_ID" => (int)$iblockId, "=CODE" => $candidate),',
' false,',
' array("nTopCount" => 1),',
' array("ID")',
' );',
'',
' if (!$result->Fetch()) {',
' return $candidate;',
' }',
' }',
'',
' throw new RuntimeException("Не найден свободный CODE за 50 попыток");',
'}',
]),
paragraph('Знак <code>=</code> в фильтре делает намерение явным: мы ищем конкретный код, а не похожую строку. В выборку достаточно взять <code>ID</code>; имя, картинка и свойства для решения о занятости не нужны. Это маленькая деталь, но она не даёт диагностическому запросу превращаться в выборку всего каталога.'),
heading('Сохраняем код вместе с элементом'),
paragraph('После проверки не нужно делать отдельный <code>Update</code> ради <code>CODE</code>. Документация <code>CIBlockElement::Add</code> допускает поле <code>CODE</code> в массиве полей. Добавляю его в тот же вызов и обязательно разбираю ошибку. Возвращённый ID доказывает запись, но ещё не доказывает, что путь компонента совпадает с проектным URL.'),
codeBlock([
'<?php',
'',
'$element = new CIBlockElement();',
'$id = $element->Add(array(',
' "IBLOCK_ID" => 12,',
' "NAME" => $name,',
' "CODE" => getFreeElementCode(12, $name),',
' "ACTIVE" => "N",',
'));',
'',
'if ($id === false) {',
' throw new RuntimeException($element->LAST_ERROR);',
'}',
'',
'// Публикуем только после проверки обязательных данных и ссылки.',
]),
heading('Последовательность проверки'),
orderedList([
'Взять два названия, которые различаются только знаками и пробелами, и получить для них кандидаты.',
'Создать первый элемент на тестовом инфоблоке с исходным кандидатом.',
'Запустить функцию для второго имени и убедиться, что она вернула суффикс, а не прежний код.',
'Прочитать оба элемента через <code>CIBlockElement::GetList</code> с тем же <code>IBLOCK_ID</code>.',
'Открыть детальные страницы и сверить ID в шаблоне или временном логе. Так мы проверяем не только данные, но и используемый компонентом маршрут.',
]),
heading('Граница этого решения'),
paragraph('Проверка «сначала <code>GetList</code>, потом <code>Add</code>» не является атомарной. Два параллельных воркера могут одновременно увидеть свободный код и попытаться сохранить одинаковое значение. Для ручного ввода и последовательного импорта этого обычно достаточно. Для параллельной синхронизации нужен отдельный проектный механизм: очередь, блокировка или код, связанный со стабильным внешним идентификатором. Какой именно — зависит от версии Bitrix, базы и требований к существующим URL.'),
paragraph('Не стоит лечить эту задачу случайным числом в каждом коде. Такой адрес перестаёт быть повторяемым при повторном импорте, а диагностика становится сложнее. Если данные поставщика имеют стабильный артикул, полезно заранее решить, будет ли он участвовать в <code>CODE</code> или останется отдельным свойством. Главное — зафиксировать правило до публикации первой тысячи карточек.'),
heading('Итог'),
paragraph('Символьный код начинается с <code>CUtil::translit</code>, но не заканчивается на нём. Сначала делаем читаемого кандидата, затем проверяем его в нужном инфоблоке, сохраняем результат вместе с элементом и отдельно открываем ссылку. Такой порядок не решает гонку параллельного импорта, зато честно показывает её границу и избавляет от тихих совпадений в обычной работе.'),
].join('\n'),
},
{
slug: 'editorial-2018-04-mechanism-bitrix-slugs',
title: 'Bitrix API. Как адрес каталога превращается в ELEMENT_CODE',
categories: ['Bitrix', 'PHP', 'ЧПУ'],
cover: '/assets/editorial/2018/bitrix-slug-route-2018.svg',
excerpt: 'Разбираем, где ЧПУ-путь становится переменной компонента, почему URL-шаблон не равен запросу к инфоблоку и как проверить связку без гадания по кешу.',
readingMinutes: 10,
sources: [parseComponentPath, makePathFromTemplate, getList],
bodyHtml: [
paragraph('Иногда символьный код в элементе правильный, а карточка всё равно отвечает 404. В другой раз тот же код работает только без раздела в адресе. Причина обычно не в транслите: путь сначала разбирает компонент, а уже потом его переменные попадают в фильтр инфоблока. Разберём один вопрос: что должно совпасть, чтобы адрес каталога действительно стал значением <code>ELEMENT_CODE</code>?'),
paragraph('Это полезно отделить в голове. Адрес <code>/catalog/kofe/classic-250-g/</code> не является запросом к таблице элементов. Для комплексного компонента Bitrix сначала определяет, какой шаблон пути подошёл, и восстанавливает переменные из URL. Только затем код компонента решает, как искать элемент. Если смешать эти два шага, начинается бесконечная правка <code>CODE</code>, хотя ошибка сидит в шаблоне или в имени переменной.'),
heading('Что делает движок ЧПУ'),
paragraph('В документации <code>CComponentEngine::ParseComponentPath</code> описано, что метод получает папку ЧПУ, массив шаблонов и текущий путь. Он возвращает код найденного шаблона, а переменные из пути записывает в переданный массив. Если шаблон не найден, результат — пустая строка. Значит, до запроса к инфоблоку можно и нужно посмотреть две вещи: какой шаблон распознан и какое значение оказалось в <code>ELEMENT_CODE</code>.'),
paragraph('Шаблон пишется относительно папки компонента. Например, для папки <code>/catalog/</code> внутри массива нужен путь <code>#SECTION_CODE#/#ELEMENT_CODE#/</code>, а не полный адрес с начальным слешем. Это не вкусовщина: документация отдельно предупреждает, что лишний слеш в шаблоне меняет результат разбора.'),
figure('/assets/editorial/2018/bitrix-slug-route-2018.svg', 'Схема: запрос браузера разбирается SEF-шаблоном, превращается в SECTION_CODE и ELEMENT_CODE, затем используется в выборке', 'Переменная из URL и элемент инфоблока живут на разных шагах. Между ними стоит проектный фильтр компонента.'),
heading('Четыре значения, которые должны совпасть'),
dataTable(
['Участок', 'Пример', 'Как проверить'],
[
['Папка ЧПУ', '<code>/catalog/</code>', 'Сравнить с <code>SEF_FOLDER</code> вызванного компонента'],
['Шаблон детали', '<code>#SECTION_CODE#/#ELEMENT_CODE#/</code>', 'Проверить отсутствие лишнего начального слеша и нужные маркеры'],
['Переменная', '<code>ELEMENT_CODE = classic-250-g</code>', 'Вывести массив, полученный после разбора, на тестовой среде'],
['Выборка', '<code>IBLOCK_ID + CODE + ACTIVE</code>', 'Сравнить фильтр компонента с контрольным <code>GetList</code>'],
['Ссылка в шаблоне', 'Тот же набор маркеров', 'Собрать URL из значений и открыть его вручную'],
],
),
heading('Минимальный воспроизводимый разбор'),
paragraph('Ниже не готовый комплексный компонент, а короткая проверка его основания. Запускаю её на тестовой странице с известным путём. Если <code>$page</code> не равен <code>detail</code>, до запроса к инфоблоку дело вообще не дошло. Если код страницы найден, но <code>ELEMENT_CODE</code> пуст, виноват шаблон или сам адрес.'),
codeBlock([
'<?php',
'',
'CModule::IncludeModule("iblock");',
'',
'$arUrlTemplates = array(',
' "detail" => "#SECTION_CODE#/#ELEMENT_CODE#/",',
');',
'$arVariables = array();',
'',
'$page = CComponentEngine::ParseComponentPath(',
' "/catalog/",',
' $arUrlTemplates,',
' $arVariables,',
' "/catalog/kofe/classic-250-g/"',
');',
'',
'if ($page !== "detail" || empty($arVariables["ELEMENT_CODE"])) {',
' throw new RuntimeException("URL не разобран как детальная страница");',
'}',
'',
'$result = CIBlockElement::GetList(',
' array(),',
' array(',
' "IBLOCK_ID" => 12,',
' "=CODE" => $arVariables["ELEMENT_CODE"],',
' "ACTIVE" => "Y",',
' ),',
' false,',
' array("nTopCount" => 1),',
' array("ID", "NAME", "CODE")',
');',
'',
'$element = $result->GetNext();',
'if (!$element) {',
' throw new RuntimeException("URL разобран, но элемент не найден");',
'}',
]),
paragraph('В примере я специально оставил фильтр небольшим. Реальный каталог может добавить раздел, права, цену, наличие или свойство витрины. Эти условия нельзя угадывать из адреса. Их нужно взять из конкретного компонента и применить в контрольной выборке. Иначе тест будет доказывать только то, что элемент вообще существует, а не то, что его видит пользователь.'),
heading('Почему генерация и разбор должны пользоваться одной формой адреса'),
paragraph('Метод <code>CComponentEngine::MakePathFromTemplate</code> подставляет значения массива в маркеры шаблона. Это удобная точка для проверки обратного направления: у нас есть <code>SECTION_CODE</code> и <code>ELEMENT_CODE</code>, собираем путь и затем разбираем его тем же шаблоном. Если после такого круга переменная изменилась или пропала, в коде сайта уже есть расхождение.'),
codeBlock([
'<?php',
'',
'$url = CComponentEngine::MakePathFromTemplate(',
' "#SECTION_CODE#/#ELEMENT_CODE#/",',
' array(',
' "SECTION_CODE" => "kofe",',
' "ELEMENT_CODE" => "classic-250-g",',
' )',
');',
'',
'// $url: kofe/classic-250-g/',
'// Для ссылки добавляем папку /catalog/ в одном месте проекта.',
]),
heading('Последовательность от ссылки до карточки'),
orderedList([
'Взять реальный адрес, который не открывается, и сохранить его без ручной правки.',
'Сверить папку и шаблон детали в параметрах вызванного компонента.',
'На тестовой среде вывести код страницы и массив переменных после <code>ParseComponentPath</code>.',
'Передать полученный <code>ELEMENT_CODE</code> в короткий <code>CIBlockElement::GetList</code> с теми же базовыми фильтрами.',
'Если элемент найден, сравнить с фильтром самого компонента: раздел, активность, права и проектные свойства.',
'Собрать обратную ссылку из тех же маркеров и повторить проверку после изменения шаблона.',
]),
heading('Частые расхождения'),
dataTable(
['Симптом', 'Где искать', 'Безопасная проверка'],
[
['Страница не определяется', 'Папка ЧПУ или шаблон детали', 'Проверить результат <code>ParseComponentPath</code> до обращения к инфоблоку'],
['Страница определяется, код пуст', 'Маркер отличается от имени, которое ждёт компонент', 'Сравнить ключи массива переменных с параметрами компонента'],
['Код есть, элемента нет', 'CODE, инфоблок, активность или дополнительный фильтр', 'Запустить <code>GetList</code> сначала с базовыми, затем с проектными условиями'],
['Ссылка формируется иначе, чем разбирается', 'Два разных URL-шаблона в шаблоне и компоненте', 'Собрать путь через <code>MakePathFromTemplate</code> и разобрать его обратно'],
],
),
heading('Ограничения'),
paragraph('Эта диагностика начинается в момент, когда PHP-компонент уже получил запрос. Если веб-сервер или правила перенаправления не передали путь в приложение, <code>ParseComponentPath</code> не сможет это исправить. Тогда проверять нужно предыдущий слой: фактический URI, правило маршрутизации и точку входа сайта. Не стоит менять <code>CODE</code>, пока не доказано, что компонент вообще получил нужную переменную.'),
paragraph('Ещё одна ловушка — перенос чужого шаблона без понимания его маркеров. В Bitrix можно назвать переменные по-разному, но компонент и его фильтр должны читать то же имя, которое восстановлено из пути. Я бы не делал универсальную функцию для всех страниц сайта: лучше зафиксировать один шаблон рядом с конкретным каталогом и покрыть его двумя-тремя адресами из реальных данных.'),
heading('Итог'),
paragraph('ЧПУ — это не «красивый CODE в базе», а связка из папки, шаблона, восстановленных переменных и фильтра элемента. Когда ссылка ведёт в 404, сначала смотрим результат разбора URL, затем выборку. После такой проверки становится видно, нужна ли правка в данных, компоненте или маршруте.'),
].join('\n'),
},
{
slug: 'editorial-2018-04-field-bitrix-slugs',
title: 'Bitrix API. Карточка открывает не тот товар: проверяем конфликт CODE',
categories: ['Bitrix', 'PHP', 'Диагностика'],
cover: '/assets/editorial/2018/bitrix-slug-conflict-2018.svg',
excerpt: 'Полевой разбор ситуации, когда адрес детали показывает другой элемент: считаем совпадения по CODE, сравниваем переменную ЧПУ с фильтром и меняем данные без потери следов.',
readingMinutes: 11,
sources: [getList, parseComponentPath, updateElement],
bodyHtml: [
paragraph('Есть неприятная ошибка, которую легко принять за кеш: открываешь карточку товара, а видишь другой товар с похожим названием. Особенно странно это выглядит после импорта — обе записи есть в админке, у обеих нормальные картинки, а URL одной вдруг показывает соседнюю. Здесь не нужно начинать с очистки кеша. Главный вопрос: как доказать конфликт <code>CODE</code> или широкий фильтр до того, как менять данные?'),
paragraph('Первое правило — не смотреть только на название. Компонент получает строку из адреса и строит по ней выборку. Если выборка возвращает несколько элементов, значение «первого» зависит от порядка и условий запроса. Если она не возвращает ничего, компонент может отдать 404 или подставить другую ветку своей логики. Поэтому нам нужны три наблюдаемых факта: что было в URL, какую переменную получил компонент и сколько записей удовлетворяют его фильтру.'),
heading('Не путать симптом и причину'),
paragraph('Похожее название не доказывает конфликт. В одном каталоге может быть несколько позиций «Classic 250 г» в разных разделах, и тогда адрес обязан содержать достаточный контекст. Наоборот, разные названия могут получить одинаковый код после нормализации. Диагностику начинаю с конкретного сломанного адреса и ID товара, который ожидали увидеть. Только потом читаю список элементов по фактическому <code>ELEMENT_CODE</code>.'),
figure('/assets/editorial/2018/bitrix-slug-conflict-2018.svg', 'Дерево диагностики неправильной карточки: путь, переменная ELEMENT_CODE, число совпадений GetList и дальнейшие действия', 'Сначала считаем набор совпадений. Кеш проверяем только после пути и данных.'),
heading('Какие данные собрать до исправления'),
dataTable(
['Факт', 'Зачем он нужен', 'Как зафиксировать'],
[
['Исходный URL', 'Показывает, что реально запросил браузер', 'Сохранить полный путь из адресной строки или access-лога'],
['Ожидаемый ID', 'Не даёт спорить о том, какая запись считается правильной', 'Взять ID из админки или из результата импорта'],
['ELEMENT_CODE', 'Связывает путь с данными компонента', 'Вывести переменную после разбора ЧПУ на тестовом стенде'],
['Все записи по CODE', 'Отличает один результат от конфликта', 'Сделать ограниченный <code>GetList</code> в том же инфоблоке'],
['Фильтр детали', 'Объясняет, почему часть записей исключена или выбрана', 'Сверить с параметрами и кодом конкретного компонента'],
],
),
heading('Контрольная выборка'),
paragraph('Документация <code>CIBlockElement::GetList</code> позволяет явно задать сортировку, фильтр, ограничение и набор полей. Для диагностики беру только те поля, которые помогают отличить записи: ID, имя, CODE, основной раздел и шаблон детального URL. Запрос не должен случайно тянуть свойства всего каталога: его задача — показать размер набора и порядок элементов.'),
codeBlock([
'<?php',
'',
'function findActiveElementsByCode($iblockId, $code)',
'{',
' $result = CIBlockElement::GetList(',
' array("ID" => "ASC"),',
' array(',
' "IBLOCK_ID" => (int)$iblockId,',
' "=CODE" => $code,',
' "ACTIVE" => "Y",',
' ),',
' false,',
' array("nTopCount" => 20),',
' array("ID", "NAME", "CODE", "IBLOCK_SECTION_ID", "DETAIL_PAGE_URL")',
' );',
'',
' $items = array();',
' while ($item = $result->GetNext()) {',
' $items[] = $item;',
' }',
'',
' return $items;',
'}',
'',
'$items = findActiveElementsByCode(12, "classic-250-g");',
'if (count($items) !== 1) {',
' throw new RuntimeException("Нужно разобрать " . count($items) . " совпадений");',
'}',
]),
paragraph('Сортировка по ID в этом примере нужна не для выбора «правильного» товара, а для повторяемого вывода. Если там два элемента, проблема уже доказана: детальный компонент не должен случайно решать, что меньший ID важнее. Дальше либо сужаем фильтр контекстом раздела, либо исправляем один из кодов по заранее выбранному правилу.'),
heading('Как отличить три разных случая'),
dataTable(
['Результат проверки', 'Что это значит', 'Следующий шаг'],
[
['0 совпадений', 'URL разобран, но элемент не проходит базовый фильтр', 'Проверить значение переменной, активность, инфоблок и шаблон ссылки'],
['1 совпадение, ID правильный', 'Данные и базовый фильтр совпали', 'Сравнить дополнительные условия компонента и только затем кеш'],
['1 совпадение, ID другой', 'Переменная из URL не соответствует ожидаемому товару', 'Проверить генерацию URL, шаблон и исходный CODE элемента'],
['2 и более совпадений', 'Фильтр недостаточно точный или коды конфликтуют', 'Решить, нужен ли контекст раздела, затем изменить конфликтующие данные'],
],
),
heading('Проверяем, что компонент получил из URL'),
paragraph('Не нужно угадать имя переменной по шаблону. Комплексный компонент разбирает путь через <code>CComponentEngine::ParseComponentPath</code> и возвращает переменные, восстановленные из маркеров. Для проблемной ссылки полезно на тестовой копии вывести <code>$page</code> и <code>$arVariables</code>. Так видно, не потерялся ли раздел и действительно ли <code>ELEMENT_CODE</code> равен строке из адреса.'),
codeBlock([
'<?php',
'',
'$templates = array(',
' "detail" => "#SECTION_CODE#/#ELEMENT_CODE#/",',
');',
'$variables = array();',
'$page = CComponentEngine::ParseComponentPath(',
' "/catalog/",',
' $templates,',
' $variables,',
' "/catalog/kofe/classic-250-g/"',
');',
'',
'if ($page !== "detail") {',
' throw new RuntimeException("Не найден шаблон detail");',
'}',
'',
'error_log(print_r($variables, true));',
]),
paragraph('Если в массиве нет <code>SECTION_CODE</code>, а детальный запрос должен учитывать раздел, коды элементов могут быть вполне корректны. Ошибка будет в URL-шаблоне или в логике компонента, который не применяет восстановленную переменную. И наоборот: если переменные верны, а <code>GetList</code> возвращает несколько записей, искать надо в данных и условиях выборки, не в роутинге.'),
heading('Исправление без потери истории'),
paragraph('Когда конфликт подтверждён, сначала выбираю правило для нового адреса: суффикс, артикул или раздел. Затем сохраняю старый URL и список мест, которые на него ссылаются. Смена <code>CODE</code> меняет адрес, поэтому публикацию лучше выполнять отдельным шагом с проверкой ссылок. Метод <code>CIBlockElement::Update</code> возвращает результат изменения; при ошибке не пропускаем <code>LAST_ERROR</code>.'),
codeBlock([
'<?php',
'',
'$element = new CIBlockElement();',
'$updated = $element->Update($duplicateId, array(',
' "CODE" => "classic-250-g-2",',
'));',
'',
'if (!$updated) {',
' throw new RuntimeException($element->LAST_ERROR);',
'}',
'',
'// После изменения снова выполняем findActiveElementsByCode().',
]),
heading('Порядок работы в продовой задаче'),
orderedList([
'Зафиксировать URL, ожидаемый ID и время, когда ошибка наблюдалась.',
'На тестовой копии получить переменные, восстановленные из того же пути.',
'Сделать выборку по фактическому <code>CODE</code> в нужном <code>IBLOCK_ID</code> и посчитать результаты.',
'Сравнить полученные ID с тем, что показывает детальный компонент после его дополнительных фильтров.',
'Если есть конфликт, выбрать новое стабильное правило кода и проверить все старые ссылки, которые важны для проекта.',
'После изменения повторить URL-проверку. Кеш и индекс обновлять только по принятому в проекте порядку, когда данные и маршрут уже доказаны.',
]),
heading('Ограничения'),
paragraph('Эта заметка не утверждает, что любое совпадение <code>CODE</code> ошибочно. В некоторых каталогах один и тот же код допустим в разных витринах или разделах, и тогда адрес и фильтр обязаны включать этот контекст. Не следует добавлять раздел в запрос автоматически: сначала нужно понять, что именно считает идентичностью текущий компонент.'),
paragraph('Также не стоит менять десятки кодов одной SQL-командой. У Bitrix есть API изменения элемента, обработчики событий и проектные зависимости от адресов. Сначала правим один доказанный конфликт на тестовых данных, проверяем маршрут и только потом составляем отдельный план для массовой миграции.'),
heading('Итог'),
paragraph('Когда адрес открывает не тот товар, удобнее не спорить о кеше, а посчитать факты. URL даёт переменную, переменная даёт набор элементов, набор показывает — это маршрут, фильтр или конфликт данных. После такой проверки изменение <code>CODE</code> становится осознанной операцией, а не попыткой наугад исправить карточку.'),
].join('\n'),
},
];
const archive = JSON.parse(await readFile(articlesPath, 'utf8'));
const archiveSlugs = new Set(archive.map((article) => article.slug));
const requiredSlugs = [
'editorial-2018-04-practice-bitrix-slugs',
'editorial-2018-04-mechanism-bitrix-slugs',
'editorial-2018-04-field-bitrix-slugs',
];
for (const slug of requiredSlugs) {
if (!archiveSlugs.has(slug)) {
throw new Error('Article not found: ' + slug);
}
}
const reports = drafts.map((draft) => {
const bodyLength = textFromHtml(draft.bodyHtml).length;
const sourceCount = draft.sources.length;
if (bodyLength < 5000 || bodyLength > 15000) {
throw new Error('Body length outside 5,000–15,000 characters: ' + draft.slug + ' (' + bodyLength + ')');
}
if (sourceCount < 2) {
throw new Error('At least two primary sources are required: ' + draft.slug);
}
if (!draft.bodyHtml.includes('<figure>') || !draft.bodyHtml.includes('<table>') || !draft.bodyHtml.includes('<pre><code>')) {
throw new Error('Figure, table or reproducible code is missing: ' + draft.slug);
}
return {
slug: draft.slug,
bodyCharacters: bodyLength,
sourceCount,
hasFigure: true,
hasTable: true,
hasCode: true,
};
});
const revisions = drafts.map((draft) => {
const { bodyHtml, sources, ...revision } = draft;
return {
...revision,
contentHtml: [bodyHtml, heading('Проверяемые источники'), sourceList(sources)].join('\n'),
};
});
if (process.argv.includes('--print-revisions')) {
console.log(JSON.stringify(revisions, null, 2));
} else if (process.argv.includes('--check')) {
console.log(JSON.stringify(reports, null, 2));
} else {
console.error('Usage: node web/scripts/upgrade-2018-04.mjs --print-revisions | --check');
process.exitCode = 1;
}
+558
View File
@@ -0,0 +1,558 @@
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function paragraph(text) {
return '<p>' + text + '</p>';
}
function heading(text) {
return '<h2>' + text + '</h2>';
}
function codeBlock(code) {
return '<pre><code>' + escapeHtml(String(code).trim()) + '</code></pre>';
}
function figure(src, alt, caption) {
return '<figure><img src="' + src + '" alt="' + alt + '" /><figcaption>' + caption + '</figcaption></figure>';
}
function orderedList(items) {
return '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
}
function bulletList(items) {
return '<ul>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ul>';
}
function dataTable(headers, rows) {
const head = '<thead><tr>' + headers.map((header) => '<th scope="col">' + header + '</th>').join('') + '</tr></thead>';
const body = '<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + cell + '</td>').join('') + '</tr>').join('') + '</tbody>';
return '<div class="table-scroll"><table>' + head + body + '</table></div>';
}
function sourceList(items) {
return '<ul>' + items.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function visibleText(html) {
return html
.replace(/<[^>]*>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function createRevision(meta, bodyParts, sources) {
const bodyHtml = bodyParts.join('\n');
const bodyLength = visibleText(bodyHtml).length;
if (bodyLength < 5000 || bodyLength > 15000) {
throw new Error(meta.slug + ': body length must be 5000–15000, got ' + bodyLength);
}
const contentHtml = [
bodyHtml,
heading('Проверяемые источники'),
sourceList(sources),
].join('\n');
for (const requiredFragment of ['<figure>', '<table>', '<pre><code>']) {
if (!contentHtml.includes(requiredFragment)) {
throw new Error(meta.slug + ': missing required fragment ' + requiredFragment);
}
}
if (sources.length < 2) {
throw new Error(meta.slug + ': at least two primary sources are required');
}
return {
...meta,
contentHtml,
bodyLength,
};
}
const jqueryOn = {
title: 'jQuery API: .on()',
url: 'https://api.jquery.com/on/',
note: 'прямая и делегированная привязка, пространства имён, повторная привязка и ограничения делегирования',
};
const jqueryOff = {
title: 'jQuery API: .off()',
url: 'https://api.jquery.com/off/',
note: 'снятие обработчика по типу события, селектору и пространству имён',
};
const jqueryHtml = {
title: 'jQuery API: .html()',
url: 'https://api.jquery.com/html/',
note: 'замена содержимого, удаление событий дочерних узлов и риск вставки непроверенной HTML-строки',
};
const jqueryAjax = {
title: 'jQuery API: jQuery.ajax()',
url: 'https://api.jquery.com/jQuery.ajax/',
note: 'jqXHR, обработчики done/fail/always, timeout и порядок завершения запроса',
};
const jquerySerialize = {
title: 'jQuery API: .serialize()',
url: 'https://api.jquery.com/serialize/',
note: 'какие поля формы попадают в URL-кодированную строку и почему файлы в неё не входят',
};
const jqueryProp = {
title: 'jQuery API: .prop()',
url: 'https://api.jquery.com/prop/',
note: 'динамические свойства disabled и checked в jQuery 1.6+',
};
const jqueryData = {
title: 'jQuery API: .data()',
url: 'https://api.jquery.com/data/',
note: 'хранение состояния рядом с DOM-узлом',
};
const jqueryRemoveData = {
title: 'jQuery API: .removeData()',
url: 'https://api.jquery.com/removeData/',
note: 'удаление ранее сохранённого значения из внутреннего хранилища jQuery',
};
const jqueryAlways = {
title: 'jQuery API: deferred.always()',
url: 'https://api.jquery.com/deferred.always/',
note: 'обработчик, который вызывается и после resolve, и после reject; подходит для освобождения интерфейса',
};
const practiceArticle = createRevision(
{
slug: 'editorial-2018-05-practice-legacy-jquery',
title: 'jQuery. Как повторно инициализировать виджет и не получить два клика',
categories: ['JavaScript', 'jQuery', 'Практика'],
cover: '/assets/editorial/2018/jquery-reinit-namespaces.svg',
excerpt: 'Разбираем маленький контракт для legacy-виджета: повторный mount снимает только свои события, назначает один обработчик и проверяется тремя вызовами подряд.',
readingMinutes: 9,
},
[
paragraph('В старом интерфейсе блок заказа часто обновляется без перезагрузки страницы. После ответа Ajax мы заново вызываем <code>mountOrderForm</code>, потому что так проще, чем помнить все места, где изменилась разметка. Через неделю один клик по кнопке уходит двумя запросами. Через месяц — тремя. Ошибка неприятна не из-за консоли: одна пользовательская команда может несколько раз изменить состояние на сервере.'),
paragraph('Главный вопрос здесь узкий: как написать инициализацию jQuery-виджета так, чтобы её можно было вызвать повторно и на кнопке оставался ровно один наш обработчик? Не будем переписывать весь legacy-код. Достаточно сделать явный контракт у одной функции <code>mount</code> и проверить его в браузере.'),
heading('Почему обработчик умножается'),
paragraph('Метод <code>.on()</code> привязывает обработчик к текущей выбранной коллекции. Если один и тот же код вызвать ещё раз, старый обработчик сам не исчезает. Официальная документация jQuery отдельно отмечает, что один обработчик можно привязать к элементу несколько раз. Поэтому проблема не в Ajax как таковом, а в функции, которая при каждом вызове только добавляет новое событие.'),
paragraph('Плохой вариант обычно выглядит безобидно. Его легко не заметить, когда страница открывается один раз и только руками.'),
codeBlock(String.raw`
function mountOrderForm() {
$('.js-order-submit').on('click', function (event) {
event.preventDefault();
sendOrder();
});
}
mountOrderForm();
mountOrderForm(); // теперь у каждой найденной кнопки два обработчика
`),
paragraph('Не надо лечить это глобальным <code>off("click")</code>. Такой вызов снимет и события соседнего кода, который может не иметь отношения к форме. Сначала нужно дать событиям нашего виджета собственное имя. В jQuery пространство имён не является иерархией, но позволяет снять обработчики по имени, не трогая чужие <code>click</code>-события.'),
figure('/assets/editorial/2018/jquery-reinit-namespaces.svg', 'Три шага повторной инициализации jQuery-виджета: снять обработчики с пространством имён, затем назначить один новый', 'Повторный вызов mount сначала очищает только события конкретного виджета, затем создаёт один обработчик.'),
heading('Контракт функции mount'),
paragraph('Для этого примера договоримся о трёх вещах. Контейнер <code>#order-panel</code> существует до вызова функции. Все события виджета получают пространство имён <code>.orderForm</code>. После выполнения функции у контейнера есть ровно один делегированный обработчик для кнопки отправки. Такая формулировка важнее названия функции: по ней можно проверить результат и не спорить о том, достаточно ли «аккуратно» написан код.'),
dataTable(
['Условие', 'Действие mount', 'Ожидаемый результат', 'Чего не делаем'],
[
['Контейнер уже есть в DOM', 'Работаем от <code>#order-panel</code>', 'Есть стабильная граница виджета', 'Не ищем кнопку по всему документу'],
['mount вызван повторно', 'Снимаем <code>.orderForm</code> с контейнера', 'Старый обработчик виджета исчезает', 'Не вызываем <code>off("click")</code>'],
['Кнопка появилась позже', 'Используем селектор во втором аргументе <code>.on()</code>', 'Клик новой кнопки доходит до контейнера', 'Не перепривязываем всё дерево после каждой мелочи'],
['Соседний код слушает click', 'Оставляем чужое пространство имён нетронутым', 'Другой модуль продолжает работать', 'Не полагаемся на порядок загрузки скриптов'],
],
),
heading('Рабочий пример'),
paragraph('В коде ниже обработчик висит на постоянном контейнере, а не на самой кнопке. Это небольшое делегирование: jQuery проверит, что событие пришло от потомка с классом <code>.js-order-submit</code>. Подробно о том, почему это полезно при замене разметки, поговорим в следующей заметке; здесь важно другое — перед новым <code>.on()</code> мы удаляем только обработчики нашей зоны.'),
codeBlock(String.raw`
(function ($) {
var eventNamespace = '.orderForm';
function sendOrder($button) {
// В проекте здесь будет Ajax-вызов или событие в общий слой.
window.console.count('order request');
$button.addClass('is-pending');
}
function mountOrderForm(root) {
var $root = $(root);
if ($root.length !== 1) {
throw new Error('Нужен один контейнер формы заказа');
}
$root.off(eventNamespace);
$root.on('click' + eventNamespace, '.js-order-submit', function (event) {
event.preventDefault();
sendOrder($(this));
});
}
window.mountOrderForm = mountOrderForm;
}(jQuery));
mountOrderForm('#order-panel');
`),
paragraph('Вызов <code>$root.off(eventNamespace)</code> затрагивает все события с пространством <code>.orderForm</code> на этом контейнере. Это удобно, когда у виджета несколько собственных событий: например, <code>click.orderForm</code> и <code>change.orderForm</code>. Но имя должно быть достаточно конкретным. Если два независимых скрипта выберут одно и то же <code>.form</code>, они начнут снимать события друг друга.'),
heading('Воспроизводимая проверка без сервера'),
paragraph('Не нужно ждать настоящего API, чтобы увидеть дефект. В консоли страницы можно собрать короткий счётчик и трижды вызвать тестовый mount. Если после одного программного клика счётчик равен единице, контракт выполнен. Если он равен трём, проблема остаётся на фронтенде и сервер здесь пока ни при чём.'),
codeBlock(String.raw`
var calls = 0;
var $panel = $('<div id="order-panel"><a class="js-order-submit" href="#">Оформить</a></div>');
function mountDemo(root) {
var $root = $(root);
$root.off('.demoOrder');
$root.on('click.demoOrder', '.js-order-submit', function (event) {
event.preventDefault();
calls += 1;
});
}
$('body').append($panel);
mountDemo('#order-panel');
mountDemo('#order-panel');
mountDemo('#order-panel');
$panel.find('.js-order-submit').trigger('click');
window.console.assert(calls === 1, 'Нужен один обработчик, получено: ' + calls);
$panel.remove();
`),
paragraph('В рабочем проекте вместо подмены <code>console.count</code> полезнее вынести обработчик в именованную функцию и проверить количество вызовов тестом. Но даже такой короткий сценарий дисциплинирует: он проверяет не внешний вид кнопки, а свойство инициализации при повторном запуске.'),
heading('Когда вызывать mount'),
paragraph('Я бы вызывал функцию в двух местах: после начальной загрузки страницы и после того кода, который действительно заменил или добавил разметку внутри <code>#order-panel</code>. Не нужно размещать вызов в каждом Ajax-обработчике приложения «на всякий случай». Чем меньше мест создают виджет, тем проще понять, почему он существует на странице.'),
paragraph('Если обновление заменяет сам <code>#order-panel</code>, старый контейнер вместе со своими событиями уйдёт из DOM. Тогда нужно передать в <code>mountOrderForm</code> уже новый контейнер после вставки. Если же постоянным остаётся внешний блок, лучше выбрать его корнем и менять только внутреннюю разметку. Это решение не универсально: оно зависит от того, какой узел реально переживает обновление.'),
heading('Последовательность внедрения'),
orderedList([
'Найти функцию, которая сейчас повторно вешает события, и назвать один постоянный контейнер виджета.',
'Выбрать уникальное пространство имён, например <code>.orderForm</code> или <code>.cartItem</code>, а не общее <code>.click</code>.',
'Перед каждым назначением вызвать <code>off</code> только для этого пространства имён на выбранном контейнере.',
'Назначить обработчик через <code>on</code> и, если кнопки меняются, передать селектор потомка.',
'Трижды вызвать mount и одним кликом подтвердить, что полезное действие срабатывает один раз.',
'Отдельно проверить реальный серверный сценарий: клиентская защита не должна быть единственным барьером повторной операции.',
]),
heading('Ограничения'),
bulletList([
'Этот приём требует jQuery 1.7 или новее, потому что использует <code>.on()</code> и <code>.off()</code>. Если проект закреплён на более старой версии, сначала надо зафиксировать допустимый путь обновления или отдельный совместимый адаптер.',
'Пространство имён защищает только события в браузере. Оно не отменяет уже отправленный запрос и не делает серверную операцию безопасной при повторе страницы, таймауте или ручном запросе.',
'Делегирование работает для событий, которые доходят до выбранного предка. Для особых типов событий и SVG у jQuery есть ограничения; их надо проверять по документации, а не переносить этот шаблон вслепую.',
'Если виджет начинает управлять десятком независимых состояний, одного обработчика уже мало. Сначала стоит разделить маленькие функции, а не превращать <code>mountOrderForm</code> в глобальный диспетчер.',
]),
heading('Итог'),
paragraph('Повторная инициализация не обязана быть опасной. Ей нужен простой договор: устойчивый корень, собственное пространство имён и проверка «несколько mount — один клик». Этот договор легко показать коллеге, а при следующей Ajax-правке не придётся угадывать, сколько обработчиков уже живёт на кнопке.'),
],
[jqueryOn, jqueryOff],
);
const mechanismArticle = createRevision(
{
slug: 'editorial-2018-05-mechanism-legacy-jquery',
title: 'jQuery. Почему кнопка перестаёт работать после .html()',
categories: ['JavaScript', 'jQuery', 'DOM'],
cover: '/assets/editorial/2018/jquery-delegation-after-html.svg',
excerpt: 'Разбираем, почему прямой обработчик исчезает вместе с заменённой разметкой, как выбрать устойчивый контейнер для делегирования и где этот приём не подходит.',
readingMinutes: 9,
},
[
paragraph('Каталог отрисовал новую страницу товаров через Ajax: <code>#products</code> получил свежий HTML, карточки на экране есть, но кнопка «В корзину» больше не реагирует. Первая реакция обычно понятна — ещё раз вызвать функцию, которая вешает click. После пары таких правок появляются уже две проблемы: у новых кнопок нет обработчика до следующей инициализации, а у старых он начинает дублироваться.'),
paragraph('Главный вопрос этой заметки: почему обработчик пропадает после <code>.html()</code> и как выбрать делегирование так, чтобы оно пережило замену карточек? Здесь важно не запомнить «вешай всё на document», а увидеть, на каком DOM-узле реально хранится обработчик и какой узел переживает обновление.'),
heading('Что делает .html() с прежней разметкой'),
paragraph('Когда <code>.html(строка)</code> задаёт новое содержимое, jQuery полностью заменяет прежних потомков контейнера. Документация отдельно предупреждает: перед заменой jQuery удаляет из дочерних элементов данные и обработчики событий. Поэтому прямой click на старой кнопке не «ломается» — он остаётся на старом DOM-узле, которого больше нет. Новая кнопка похожа внешне, но для браузера это другой объект.'),
paragraph('В этом легко убедиться на коротком примере. Сначала обработчик привязан непосредственно к найденной кнопке. После замены HTML в контейнере новая кнопка появляется без этого обработчика.'),
codeBlock(String.raw`
var $products = $('#products');
function buy(event) {
event.preventDefault();
window.console.log('Товар добавлен');
}
$products.find('.js-buy').on('click', buy);
$products.html('<a class="js-buy" href="/cart/add/17">Купить</a>');
// Эта новая ссылка создана после .on(), поэтому buy для неё не назначен.
$products.find('.js-buy').trigger('click');
`),
paragraph('Это не повод каждый раз обходить все кнопки после рендера. Прямая привязка нормальна, когда элемент стабилен и событие относится только к нему. Но в списке, который полностью перерисовывается, она делает жизненный цикл события зависимым от каждой вставки HTML. Такую зависимость лучше перенести на постоянный контейнер.'),
figure('/assets/editorial/2018/jquery-delegation-after-html.svg', 'Сравнение прямого обработчика на кнопке и делегированного обработчика на устойчивом контейнере после замены HTML', 'Прямой обработчик уходит вместе со старой кнопкой. Делегированный остаётся на контейнере и получает клик от новой дочерней кнопки.'),
heading('Прямая привязка и делегирование — это разные владельцы'),
paragraph('У <code>.on()</code> без селектора обработчик привязан к текущему набору элементов. Если передать селектор вторым аргументом, обработчик остаётся на выбранном предке и вызывается, когда событие всплывает от подходящего потомка. Документация jQuery называет эти варианты direct и delegated. Для нашего каталога владелец события должен быть не карточкой, а <code>#products</code>, если этот блок не заменяется целиком.'),
dataTable(
['Подход', 'Где хранится обработчик', 'Что случится после .html()', 'Подходит для'],
[
['Прямой <code>$(".js-buy").on(...)</code>', 'На найденных кнопках', 'Старые узлы удалены, новым кнопкам нужен новый bind', 'Стабильная одиночная кнопка или плагин, которому нужен именно элемент'],
['Делегированный <code>$root.on(..., ".js-buy", ...)</code>', 'На постоянном <code>$root</code>', 'Новая кнопка под тем же корнем начинает работать сразу', 'Карточки, строки таблицы, пункты меню, которые заменяются'],
['На <code>document</code>', 'На самом верхнем доступном узле', 'Технически может пережить почти любую замену', 'Только когда ближнего постоянного контейнера действительно нет'],
],
),
heading('Исправление на устойчивом контейнере'),
paragraph('Выберем ближайший узел, который существует до и после обновления списка. Здесь это <code>#products</code>. Перед назначением снимем только своё пространство имён: так повторная инициализация не будет плодить обработчики, а соседние click-события останутся на месте.'),
codeBlock(String.raw`
(function ($) {
function addToCart(event) {
event.preventDefault();
var $link = $(this);
var productId = $link.data('product-id');
if (!productId) {
window.console.warn('У кнопки нет product-id');
return;
}
window.console.log('Добавляем товар ' + productId);
}
function mountProductList(root) {
var $root = $(root);
$root.off('.productList');
$root.on('click.productList', '.js-buy', addToCart);
}
window.mountProductList = mountProductList;
}(jQuery));
mountProductList('#products');
`),
paragraph('Теперь серверный ответ может заменить внутренности <code>#products</code>, а обработчик остаётся на самом контейнере. Он увидит клик, который всплывёт от новой ссылки и совпадёт с селектором <code>.js-buy</code>. Если проект меняет и сам <code>#products</code>, этот код не сделает чудо: нужно вызвать <code>mountProductList</code> для нового контейнера или выбрать более внешний, но всё ещё локальный корень.'),
heading('Проверяем разметку и событие по отдельности'),
paragraph('В legacy-проекте легко перепутать три причины: Ajax вернул не ту разметку, селектор не совпал или событие не дошло до корня. Поэтому я бы проверял их раздельно. Сначала подменяю HTML статической строкой, затем запускаю программный click, и только после этого возвращаю реальный запрос. Так сетевой сбой не маскирует ошибку жизненного цикла DOM.'),
codeBlock(String.raw`
var calls = 0;
var $root = $('<div id="products"><a class="js-buy" data-product-id="17" href="#">Купить</a></div>');
$('body').append($root);
$root.off('.demo');
$root.on('click.demo', '.js-buy', function (event) {
event.preventDefault();
calls += 1;
});
$root.html('<a class="js-buy" data-product-id="18" href="#">Купить другую</a>');
$root.find('.js-buy').trigger('click');
window.console.assert(calls === 1, 'Делегированный click должен дойти до корня');
$root.remove();
`),
paragraph('Если проверка не проходит, сначала смотрим на корень: он существует в момент вызова <code>.on()</code>, внутри него действительно лежит новая кнопка, и её класс совпадает с селектором? Затем проверяем тип события. В документации jQuery есть важные исключения: делегированные обработчики не работают для SVG, а некоторые события не всплывают. Для таких случаев нельзя механически переносить click-шаблон.'),
heading('Почему document — не первая точка'),
paragraph('У <code>document</code> есть соблазнительное свойство: он почти всегда живёт дольше виджета. Но документация jQuery советует выбирать место как можно ближе к целевым элементам. На большой странице делегирование высокочастотных событий сверху заставляет jQuery сравнивать селекторы по длинному пути всплытия. Для click на небольшом участке разница может быть незаметна, но архитектурно всё равно лучше, когда каталог слушает каталог, а не весь сайт.'),
paragraph('Есть и практическая причина. Локальный корень показывает границу ответственности: код карточек не должен случайно перехватить похожую кнопку в модальном окне или в шапке. Селектор <code>.js-buy</code> становится понятным только в контексте <code>#products</code>.'),
heading('Отдельный риск: строка HTML — это не безопасные данные'),
paragraph('У <code>.html()</code> есть ещё один неприятный край. Документация jQuery предупреждает, что методы, принимающие HTML-строку, потенциально выполняют код из вставленных тегов или атрибутов. Поэтому в пример выше строка попала только как тестовая разметка, написанная в исходнике. Нельзя передавать в <code>.html()</code> необработанный параметр URL, текст из формы или поле API, если сервер не гарантирует его безопасное формирование.'),
heading('Порядок исправления'),
orderedList([
'Найти точный вызов <code>.html()</code> или другой код, который заменяет дочерние карточки.',
'Проверить, какой ближайший контейнер не заменяется при обновлении.',
'Снять со стабильного контейнера только события конкретного виджета по пространству имён.',
'Назначить делегированный обработчик с простым селектором потомка.',
'Подменить разметку тестовой строкой и вызвать click программно, чтобы отделить DOM-проблему от сети.',
'Вернуть реальный Ajax и отдельно проверить, что HTML приходит из доверенного источника и соответствует ожидаемому контракту.',
]),
heading('Ограничения'),
bulletList([
'Делегирование не заменяет прямую привязку во всех случаях. Если нужен обработчик на самом элементе плагина или событие не всплывает, придётся выбрать другой контракт.',
'По документации jQuery делегированные обработчики не работают для SVG. Для интерактивных SVG нельзя рассчитывать на этот пример без отдельной проверки.',
'Слишком общий корень и тяжёлый селектор могут создать лишнюю работу при частых событиях. Выбираем ближайший живой контейнер и простую границу.',
'Починка click не решает вопрос повторной серверной операции. Контракт формы и запросов нужно проверять отдельно.',
]),
heading('Итог'),
paragraph('После <code>.html()</code> новая кнопка — это новый DOM-узел без старого прямого обработчика. Делегирование решает ровно эту задачу, если обработчик живёт на устойчивом и близком контейнере. Когда мы называем владельца события и проверяем замену разметки отдельно от Ajax, исчезает и необходимость в случайных повторных bind.'),
],
[jqueryHtml, jqueryOn, jqueryOff, jqueryData],
);
const fieldArticle = createRevision(
{
slug: 'editorial-2018-05-field-legacy-jquery',
title: 'jQuery. Как не отправить legacy-форму дважды',
categories: ['JavaScript', 'jQuery', 'Ajax'],
cover: '/assets/editorial/2018/jquery-ajax-form-contract.svg',
excerpt: 'Практический контракт Ajax-формы: один активный jqXHR, корректная сериализация, явные ветки успеха и ошибки, обязательное освобождение кнопки.',
readingMinutes: 10,
},
[
paragraph('Форма заказа в старом интерфейсе может отправиться дважды не только из-за двойного клика. Пользователь нажал Enter, скрипт повторно повесил submit, кнопка осталась активной до ответа или код решил «на всякий случай» повторить запрос. В браузере это выглядит как маленькая ошибка. На сервер могут уйти два одинаковых POST, а последствия уже зависят от предметной области.'),
paragraph('Главный вопрос здесь такой: как сделать Ajax-форму, которая допускает один активный запрос в текущем DOM-экземпляре, честно показывает ошибку и в любом исходе возвращает интерфейс в готовое состояние? Это не заменяет серверную защиту операции. Зато убирает повторную отправку, созданную именно фронтенд-кодом, и даёт понятную точку диагностики.'),
heading('Сначала определим, что именно отправляет форма'),
paragraph('Метод <code>.serialize()</code> строит URL-кодированную строку из успешных контролов формы. Практическое следствие простое: у поля должен быть <code>name</code>, выключенные поля не попадут в набор, неотмеченный checkbox тоже не попадёт, а файл через <code>.serialize()</code> не отправится. Поэтому перед переписыванием обработчика стоит открыть Network и сравнить фактические данные запроса с тем, что ожидает сервер.'),
paragraph('Я предпочитаю сериализовать сам <code>&lt;form&gt;</code>, а не объединять вручную все <code>input</code> на странице. Так не появляется дубль, когда в выборку по ошибке попали и форма, и её дочерние поля. Этот нюанс прямо описан в документации jQuery.'),
dataTable(
['Состояние формы', 'Что хранится на форме', 'Что видит пользователь', 'Следующий переход'],
[
['Готова', 'Ключ <code>orderRequest</code> отсутствует', 'Кнопка доступна', 'submit создаёт один jqXHR'],
['Запрос идёт', 'В <code>.data()</code> лежит маркер или jqXHR', 'Кнопка отключена', 'Повторный submit сразу выходит'],
['Успех', 'Сервер вернул ожидаемый ответ', 'Показываем подтверждённый результат', 'always освобождает интерфейс'],
['Ошибка или timeout', 'jqXHR отклонён', 'Показываем понятную ошибку', 'always освобождает интерфейс'],
],
),
figure('/assets/editorial/2018/jquery-ajax-form-contract.svg', 'Состояния Ajax-формы: готова, запрос отправлен, успех или ошибка, затем обязательное освобождение интерфейса', 'Ветка успеха и ветка ошибки разные, а освобождение кнопки живёт в always и не зависит от параметров ответа.'),
heading('Минимальная разметка и граница обработчика'),
paragraph('Пусть форма существует на странице постоянно. Скрытый токен и поля уже выдаёт сервер; пример не придумывает их значение. Важно только, чтобы каждое отправляемое поле имело имя, а кнопка была внутри формы.'),
codeBlock(String.raw`
<form id="order-form" action="/order/create" method="post">
<input type="hidden" name="csrf_token" value="серверное_значение">
<label>
Почта
<input name="email" type="email" required>
</label>
<label>
<input name="agree" type="checkbox" value="Y">
Согласен с условиями
</label>
<button type="submit">Оформить</button>
<p class="js-order-message" aria-live="polite"></p>
</form>
`),
paragraph('Клиентский замок я храню через <code>.data()</code> на самой форме. Это локально: на странице с двумя независимыми формами их состояния не смешаются. Для кнопки использую <code>.prop("disabled", true)</code>, а не <code>.attr</code>, потому что <code>disabled</code> — динамическое свойство DOM; jQuery отдельно рекомендует <code>.prop()</code> для <code>disabled</code> и <code>checked</code>.'),
heading('Рабочий обработчик'),
paragraph('Пример написан для jQuery 3.x и использует <code>done</code>, <code>fail</code> и <code>always</code> у объекта <code>jqXHR</code>. Метод <code>$.ajax()</code> возвращает jqXHR с Promise-интерфейсом. В <code>done</code> мы разбираем ответ, в <code>fail</code> — транспортную ошибку, а в <code>always</code> выполняем действие, которому не нужны параметры ответа: освобождаем форму.'),
codeBlock(String.raw`
(function ($) {
var requestKey = 'orderRequest';
function showMessage($form, text, isError) {
$form.find('.js-order-message')
.toggleClass('is-error', isError)
.text(text);
}
function unlock($form, $button) {
$form.removeData(requestKey);
$button.prop('disabled', false);
}
function submitOrder(event) {
event.preventDefault();
var $form = $(this);
var $button = $form.find('[type="submit"]');
if ($form.data(requestKey)) {
return;
}
$form.data(requestKey, true);
$button.prop('disabled', true);
showMessage($form, 'Отправляем…', false);
var request;
try {
request = $.ajax({
url: $form.attr('action'),
type: $form.attr('method') || 'POST',
data: $form.serialize(),
dataType: 'json',
timeout: 10000
});
} catch (error) {
unlock($form, $button);
showMessage($form, 'Не удалось начать запрос', true);
return;
}
$form.data(requestKey, request);
request
.done(function (response) {
if (!response || response.ok !== true || typeof response.orderNumber === 'undefined') {
showMessage($form, 'Сервер не подтвердил оформление', true);
return;
}
showMessage($form, 'Заказ принят: ' + response.orderNumber, false);
})
.fail(function (xhr, status) {
var text = status === 'timeout'
? 'Сервер не ответил вовремя. Проверьте статус заказа перед повтором.'
: 'Не удалось отправить форму. Попробуйте позже.';
showMessage($form, text, true);
})
.always(function () {
unlock($form, $button);
});
}
$('#order-form')
.off('submit.orderForm')
.on('submit.orderForm', submitOrder);
}(jQuery));
`),
paragraph('Маркер <code>true</code> записывается до старта Ajax. После успешного создания jqXHR он заменяется на сам объект запроса: это удобно для отладки в консоли, но в примере не используется для отмены. Если <code>$.ajax()</code> не удалось начать синхронно, блок <code>catch</code> снимает маркер и возвращает кнопку. В обычном сетевом отказе код пойдёт через <code>fail</code>, а <code>always</code> всё равно вернёт форму к начальному состоянию.'),
heading('Что именно проверяет этот код'),
paragraph('Первая защита — обработчик <code>submit</code>, а не только click на кнопке. Поэтому Enter в поле проходит тем же путём. Вторая защита — состояние на форме. Если тот же submit придёт, пока есть маркер, функция выходит без второго <code>$.ajax()</code>. Третья — переключение кнопки. Оно даёт пользователю видимый сигнал и уменьшает шанс случайного повторного действия, но не является единственным условием корректности.'),
codeBlock(String.raw`
// Временный диагностический крючок для staging:
var sent = 0;
var originalAjax = $.ajax;
$.ajax = function () {
sent += 1;
return originalAjax.apply(this, arguments);
};
$('#order-form').trigger('submit');
$('#order-form').trigger('submit');
window.console.assert(sent === 1, 'Форма не должна запускать второй Ajax до завершения первого');
`),
paragraph('Такую подмену не надо оставлять в production. Она нужна, чтобы коротко воспроизвести контракт: два submit подряд должны создать один Ajax-вызов. Для реального теста вместо неё лучше замокать endpoint или проверять запросы в браузерном тесте. Но если счётчик сразу показывает два вызова, искать ошибку на сервере ещё рано.'),
heading('Почему success не равен завершению интерфейса'),
paragraph('Иногда старый код разблокирует кнопку только в callback успеха. Тогда при timeout, 500 или ошибке сети пользователь остаётся с выключенной формой и обновляет страницу. У jqXHR есть <code>done</code>, <code>fail</code> и <code>always</code>; документация jQuery рекомендует не анализировать аргументы в <code>always</code>, потому что при resolve и reject они различаются. Это как раз подходящее место для одинакового действия: убрать локальный маркер и вернуть кнопку.'),
paragraph('Успешный HTTP-ответ тоже не обязательно означает, что операция готова. В примере договор сервера требует <code>response.ok === true</code> и номер заказа. Если API проекта отвечает иначе, нужно описать именно его контракт: какие поля обязательны, где лежит текст ошибки, можно ли повторить запрос и когда результат считается подтверждённым. Не стоит считать успехом любой JSON только потому, что запрос завершился без сетевой ошибки.'),
heading('Последовательность внедрения'),
orderedList([
'Открыть текущую форму в браузере и зафиксировать фактический URL, метод, поля и ожидаемый ответ API.',
'Проверить, что необходимые поля имеют <code>name</code>; отдельно решить, как отправляются файлы, потому что <code>.serialize()</code> их не включает.',
'Перевести обработку на <code>submit</code> и снять только прежнее событие формы через уникальное пространство имён.',
'Записать маркер до отправки, выключить кнопку через <code>.prop()</code> и создать один jqXHR.',
'Разделить подтверждённый бизнес-ответ, ошибку транспорта и общее освобождение интерфейса.',
'Проверить два submit подряд, timeout и ответ API с ошибкой; после каждого сценария форма должна либо показать результат, либо снова стать доступной.',
]),
heading('Ограничения'),
bulletList([
'Клиентский маркер существует только в текущем DOM. Обновление страницы, второй браузер, ручный HTTP-запрос или повтор после timeout могут создать новый запрос. Критичная операция должна быть защищена на сервере по правилам конкретного домена.',
'В примере нет загрузки файлов. Документация jQuery указывает, что file input не сериализуется через <code>.serialize()</code>; для него нужен отдельный согласованный транспорт.',
'Не показываем номер заказа из любого произвольного ответа. Формат <code>ok</code> и <code>orderNumber</code> — пример контракта, который сервер должен подтвердить.',
'Timeout — это отсутствие ответа за выбранный интервал, а не доказательство, что сервер ничего не сделал. Поэтому текст ошибки не обещает безопасный повтор, пока проект не определил проверку статуса операции.',
]),
heading('Итог'),
paragraph('У legacy Ajax-формы должно быть немного состояний и ни одного скрытого перехода: формы нет в запросе, форма ждёт один jqXHR, затем показывает подтверждённый результат или ошибку и в любом случае освобождает интерфейс. Такой код не решает серверную идемпотентность, но перестаёт создавать собственные дубли и даёт читабельную точку для следующей диагностики.'),
],
[jqueryAjax, jquerySerialize, jqueryProp, jqueryData, jqueryRemoveData, jqueryAlways],
);
export const revisions = [practiceArticle, mechanismArticle, fieldArticle]
.map(({ bodyLength, ...revision }) => revision);
if (process.argv[1]?.endsWith('/upgrade-2018-05.mjs')) {
if (process.argv.includes('--print-revisions')) {
process.stdout.write(JSON.stringify(revisions, null, 2) + '\n');
} else {
process.stderr.write('Usage: node web/scripts/upgrade-2018-05.mjs --print-revisions\n');
process.exitCode = 1;
}
}