This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
# Редакционный стандарт качества
|
||||||
|
|
||||||
|
Этот стандарт применяется к каждой переработанной статье. Он нужен не для того, чтобы сделать все тексты одинаковыми, а чтобы читатель получал законченное расследование, а не яркий заголовок с короткой заметкой.
|
||||||
|
|
||||||
|
## До написания
|
||||||
|
|
||||||
|
- Выбрать конкретную проблему, наблюдаемый симптом и практический результат для читателя.
|
||||||
|
- Проверить как минимум два первичных, нормативных или официальных источника. Для исторического API отдельно назвать версионные ограничения.
|
||||||
|
- Собрать один воспроизводимый пример: код, запрос, конфигурацию, замер или диагностическую последовательность.
|
||||||
|
- Подобрать собственный визуальный материал: схема, диаграмма, скриншот с разрешением на публикацию или иллюстрация. У изображения должны быть осмысленные `alt` и подпись.
|
||||||
|
|
||||||
|
## Каркас статьи
|
||||||
|
|
||||||
|
- В первых двух абзацах назвать исходную ситуацию и цену ошибки.
|
||||||
|
- Показать механизм, а не только рецепт: что меняется, кто владеет состоянием, где проходит граница ответственности.
|
||||||
|
- Дать читателю рабочий пример и объяснить, какие значения в нём проектные.
|
||||||
|
- Добавить минимум одну таблицу: сравнение вариантов, матрицу симптомов, контракт данных или последовательность проверки.
|
||||||
|
- Добавить минимум один рисунок или диаграмму, один пример и один проверяемый источник.
|
||||||
|
- Закончить конкретным порядком действий, ограничениями и тем, что именно следует проверить в своём проекте.
|
||||||
|
|
||||||
|
## Голос автора
|
||||||
|
|
||||||
|
- Для 2017–2018 годов — практичная, тёплая заметка инженера: «давайте разберём», осторожные выводы, внимание к реальной ошибке и следующему шагу.
|
||||||
|
- Не подменять опыт общими фразами вроде «важно учитывать» или «магическая сила». Каждое обобщение должно опираться на случай, код, таблицу или источник.
|
||||||
|
- Не делать вид, что исторический автор уже знает инструменты и практики 2027 года. Поздние материалы могут становиться системнее, но развитие должно быть постепенным.
|
||||||
|
- Термины и сокращения раскрываются при первом появлении, если они не очевидны из контекста кода.
|
||||||
|
|
||||||
|
## Тройное ревью перед публикацией
|
||||||
|
|
||||||
|
1. **Факты и техника.** Сверить утверждения с источниками, проверить пример, версионные оговорки, ссылки и отсутствие ложных обещаний.
|
||||||
|
2. **Редактура и голос.** Проверить постановку проблемы, полноту раскрытия, естественность тона соответствующего года, повторы и ясность переходов.
|
||||||
|
3. **Визуал и выпуск.** Открыть изображения и диаграммы, проверить таблицы на узком экране, доступность `alt`/подписей, JSON, автоматический аудит и production-сборку.
|
||||||
|
|
||||||
|
Результат каждой ручной проверки фиксируется рядом с партией в `editorial/reviews/`.
|
||||||
+2
-2
@@ -7,7 +7,7 @@
|
|||||||
- Период: январь 2018 — декабрь 2027.
|
- Период: январь 2018 — декабрь 2027.
|
||||||
- Ритм: три публикации в месяц — практическая инструкция, объяснение механизма и разбор/кейс.
|
- Ритм: три публикации в месяц — практическая инструкция, объяснение механизма и разбор/кейс.
|
||||||
- Уже опубликованные статьи занимают один слот в октябре 2018 и январе 2019; для соблюдения ритма к ним добавляются только две новые публикации.
|
- Уже опубликованные статьи занимают один слот в октябре 2018 и январе 2019; для соблюдения ритма к ним добавляются только две новые публикации.
|
||||||
- Каждый новый материал содержит проблему, минимальную схему, проверку, ограничения и ссылки на источники.
|
- Каждый переработанный материал проходит отдельный редакционный стандарт из `QUALITY_STANDARD.md`: проблема, исследование, пример, визуальное объяснение, проверка и источники.
|
||||||
- Стилистика меняется от тёплой практической заметки 2018 года к спокойному системному разбору и наставническому тону 2027 года.
|
- Стилистика меняется от тёплой практической заметки 2018 года к спокойному системному разбору и наставническому тону 2027 года.
|
||||||
|
|
||||||
## Исследовательская библиотека
|
## Исследовательская библиотека
|
||||||
@@ -33,4 +33,4 @@
|
|||||||
|
|
||||||
## Публикация
|
## Публикация
|
||||||
|
|
||||||
Скрипт \`web/scripts/publishEditorialArchive.mjs\` создаёт идемпотентный архив: удаляет только записи с префиксом \`editorial-\`, сохраняет исходные статьи и заново добавляет подготовленные публикации. После его запуска необходимо проверять JSON и выполнять \`npm run build\` из каталога \`web\`.
|
Первичный массовый генератор `web/scripts/publishEditorialArchive.mjs` выведен из использования: он не соответствует редакционному стандарту и не должен перезаписывать доработанные статьи. Переработка идёт небольшими тематическими тройками поверх существующего архива. Для каждой тройки есть источник текста, автоматическая проверка, ручное трёхкратное ревью и проверка сборки.
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Январь 2018 — ручное редакционное ревью
|
||||||
|
|
||||||
|
Партия:
|
||||||
|
|
||||||
|
- `editorial-2018-01-practice-bitrix-elements`
|
||||||
|
- `editorial-2018-01-mechanism-bitrix-elements`
|
||||||
|
- `editorial-2018-01-field-bitrix-elements`
|
||||||
|
|
||||||
|
Дата проверки: 31 июля 2026 года. Тексты сохраняют даты исходной публикационной траектории; это дата реконструкции и редакционного выпуска.
|
||||||
|
|
||||||
|
## 1. Факты и техника — пройдено
|
||||||
|
|
||||||
|
- Сверены контракты `CIBlockElement::Add`, `SetPropertyValuesEx`, `GetList` и события `OnBeforeIBlockElementAdd` с официальной документацией Bitrix.
|
||||||
|
- Утверждение о товарном слое вынесено в отдельный блок: элемент инфоблока не объявлен достаточным условием видимости в каталоге.
|
||||||
|
- Устаревший `CCatalogProduct::Add` отмечен как исторический API со ссылкой на актуальную карточку документации, а не выдан за современный рецепт.
|
||||||
|
- В примеры добавлены явные `PRODUCT_IBLOCK_ID` и подключение модуля там, где они были скрытой зависимостью.
|
||||||
|
- В статьях нет обещаний, что очистка кеша, событие или повторный `Add` универсально устранят ошибку.
|
||||||
|
|
||||||
|
## 2. Редактура и голос — пройдено
|
||||||
|
|
||||||
|
- У каждой статьи свой вопрос: готовность операции, граница события и диагностика невидимости. Три текста не пересказывают друг друга.
|
||||||
|
- Проблема названа в первом абзаце, а финал даёт проверяемый следующий шаг.
|
||||||
|
- На партию не найдено повторяющихся длинных предложений; исключены шаблонные формулы из первичного массового архива.
|
||||||
|
- Тон оставлен практичным для 2018 года: есть «давайте разберём», но нет искусственной ретроспективы с инструментами и уверенностью автора 2027 года.
|
||||||
|
- Глубина после финальной правки: 5 102, 5 634 и 5 085 символов обычного текста; 9, 9 и 10 минут чтения соответственно.
|
||||||
|
|
||||||
|
## 3. Визуал и выпуск — пройдено
|
||||||
|
|
||||||
|
- Каждая статья содержит самостоятельный рисунок с `alt` и подписью; первая — авторскую редакционную иллюстрацию, две другие — адаптированные вертикальные SVG-схемы.
|
||||||
|
- В реальном рендере проверены ширины 1280px и 375px. На 375px нет горизонтального скролла страницы; таблицы прокручиваются внутри `.table-scroll`.
|
||||||
|
- После первой мобильной проверки широкие схемы заменены на вертикальные: текст и последовательность шагов остаются читаемыми.
|
||||||
|
- `xmllint` подтвердил корректность SVG. Автоматический аудит подтвердил наличие рисунка, таблицы, кода, источников и достаточной глубины для всех трёх материалов.
|
||||||
|
- `npm run build` успешно собрал 374 статические страницы; в консоли страницы не было предупреждений и ошибок.
|
||||||
|
|
||||||
|
Статус: готово к публикации как первая качественно переработанная тройка. Остальной массовый архив не считается прошедшим этот стандарт и должен обновляться такими же проверяемыми тематическими партиями.
|
||||||
@@ -240,6 +240,39 @@ h3 {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-content .table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 28px 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 620px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content th,
|
||||||
|
.article-content td {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content th {
|
||||||
|
background: #f0ece2;
|
||||||
|
color: #443a2d;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content tr:last-child td {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.article-content pre {
|
.article-content pre {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
+21
-18
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"admin:posts": "node ../local-admin/posts-admin.mjs",
|
"admin:posts": "node ../local-admin/posts-admin.mjs",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
"audit:articles": "node scripts/audit-quality-batch.mjs",
|
||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1360" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Жизненный цикл CIBlockElement Add</title>
|
||||||
|
<desc id="desc">Вертикальная последовательность: входные данные, общий обработчик, запись, контрольная публичная выборка и готовый пользовательский результат.</desc>
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.bg { fill: #fffdfa; }
|
||||||
|
.title { fill: #1e252d; font: 700 48px Arial, sans-serif; }
|
||||||
|
.sub { fill: #64707d; font: 28px Arial, sans-serif; }
|
||||||
|
.step { fill: #edf5f2; stroke: #216869; stroke-width: 3; }
|
||||||
|
.event { fill: #eef3fa; stroke: #355c9a; stroke-width: 3; }
|
||||||
|
.check { fill: #fff4e8; stroke: #b4432a; stroke-width: 3; }
|
||||||
|
.result { fill: #e5f2ed; stroke: #216869; stroke-width: 3; }
|
||||||
|
.step-title { fill: #1e252d; font: 700 34px Arial, sans-serif; text-anchor: middle; }
|
||||||
|
.step-copy { fill: #44515d; font: 26px Arial, sans-serif; text-anchor: middle; }
|
||||||
|
.note { fill: #65492e; font: 700 28px 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="1000" height="1360"/>
|
||||||
|
<text class="title" x="80" y="84">Путь CIBlockElement::Add</text>
|
||||||
|
<text class="sub" x="80" y="128">ID — важная контрольная точка, но не финальный критерий готовности.</text>
|
||||||
|
|
||||||
|
<rect class="step" x="100" y="200" width="800" height="138" rx="26"/>
|
||||||
|
<text class="step-title" x="500" y="253">1. Форма или импорт → сервис</text>
|
||||||
|
<text class="step-copy" x="500" y="300">Поля, внешний ключ и контекст операции.</text>
|
||||||
|
|
||||||
|
<path class="arrow" d="M500 338 V415"/>
|
||||||
|
|
||||||
|
<rect class="event" x="100" y="430" width="800" height="148" rx="26"/>
|
||||||
|
<text class="step-title" x="500" y="484">2. OnBeforeIBlockElementAdd</text>
|
||||||
|
<text class="step-copy" x="500" y="528">Общий инвариант: уточнить поля</text>
|
||||||
|
<text class="step-copy" x="500" y="563">или отменить запись с понятной причиной.</text>
|
||||||
|
|
||||||
|
<path class="arrow" d="M500 578 V655"/>
|
||||||
|
|
||||||
|
<rect class="step" x="100" y="670" width="800" height="140" rx="26"/>
|
||||||
|
<text class="step-title" x="500" y="724">3. CIBlockElement::Add</text>
|
||||||
|
<text class="step-copy" x="500" y="770">Возвращает ID либо false + LAST_ERROR.</text>
|
||||||
|
|
||||||
|
<path class="arrow" d="M500 810 V887"/>
|
||||||
|
|
||||||
|
<rect class="check" x="100" y="902" width="800" height="166" rx="26"/>
|
||||||
|
<text class="step-title" x="500" y="956">4. Контрольная публичная выборка</text>
|
||||||
|
<text class="step-copy" x="500" y="1000">ACTIVE, даты, раздел, свойства, URL,</text>
|
||||||
|
<text class="step-copy" x="500" y="1035">а для каталога — цена и остаток по сценарию.</text>
|
||||||
|
|
||||||
|
<path class="arrow" d="M500 1068 V1145"/>
|
||||||
|
|
||||||
|
<rect class="result" x="100" y="1160" width="800" height="118" rx="26"/>
|
||||||
|
<text class="step-title" x="500" y="1212">5. Пользователь видит готовый результат</text>
|
||||||
|
<text class="step-copy" x="500" y="1252">Только теперь можно считать сценарий завершённым.</text>
|
||||||
|
|
||||||
|
<text class="note" x="500" y="1324">«Элемент сохранён» и «элемент готов» — разные состояния.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -0,0 +1,61 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1560" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Диагностика элемента, который не виден в каталоге</title>
|
||||||
|
<desc id="desc">Вертикальное дерево вопросов от результата Add до публичного отображения товара.</desc>
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.bg { fill: #fffdfa; }
|
||||||
|
.title { fill: #1e252d; font: 700 46px Arial, sans-serif; }
|
||||||
|
.sub { fill: #64707d; font: 28px Arial, sans-serif; }
|
||||||
|
.question { fill: #edf2f7; stroke: #4e6a85; stroke-width: 3; }
|
||||||
|
.failure { fill: #fff0eb; stroke: #b4432a; stroke-width: 3; }
|
||||||
|
.success { fill: #e5f2ed; stroke: #216869; stroke-width: 3; }
|
||||||
|
.box-title { fill: #1e252d; font: 700 33px Arial, sans-serif; text-anchor: middle; }
|
||||||
|
.box-copy { fill: #44515d; font: 25px Arial, sans-serif; text-anchor: middle; }
|
||||||
|
.yes { fill: #216869; font: 700 27px Arial, sans-serif; }
|
||||||
|
.no { fill: #b4432a; font: 700 27px Arial, sans-serif; }
|
||||||
|
.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="1000" height="1560"/>
|
||||||
|
<text class="title" x="70" y="82">Элемент не виден в каталоге</text>
|
||||||
|
<text class="sub" x="70" y="128">Не начинаем с кеша. Идём от факта записи к публичной выборке.</text>
|
||||||
|
|
||||||
|
<rect class="question" x="100" y="200" width="800" height="140" rx="26"/>
|
||||||
|
<text class="box-title" x="500" y="255">1. Add вернул ID и нет LAST_ERROR?</text>
|
||||||
|
<text class="box-copy" x="500" y="303">Нет → логируем входные поля, права и причину ошибки.</text>
|
||||||
|
<path class="arrow" d="M500 340 V410"/>
|
||||||
|
<text class="yes" x="524" y="386">да</text>
|
||||||
|
|
||||||
|
<rect class="question" x="100" y="425" width="800" height="140" rx="26"/>
|
||||||
|
<text class="box-title" x="500" y="480">2. ACTIVE, даты и раздел подходят?</text>
|
||||||
|
<text class="box-copy" x="500" y="528">Нет → исправляем условия публикации, а не шаблон.</text>
|
||||||
|
<path class="arrow" d="M500 565 V635"/>
|
||||||
|
<text class="yes" x="524" y="611">да</text>
|
||||||
|
|
||||||
|
<rect class="question" x="100" y="650" width="800" height="140" rx="26"/>
|
||||||
|
<text class="box-title" x="500" y="705">3. Есть свойства и товарный слой?</text>
|
||||||
|
<text class="box-copy" x="500" y="753">Нет → проверяем SKU, цену, остаток и обязательные связи.</text>
|
||||||
|
<path class="arrow" d="M500 790 V860"/>
|
||||||
|
<text class="yes" x="524" y="836">да</text>
|
||||||
|
|
||||||
|
<rect class="question" x="100" y="875" width="800" height="140" rx="26"/>
|
||||||
|
<text class="box-title" x="500" y="930">4. Совпадает фильтр публичного компонента?</text>
|
||||||
|
<text class="box-copy" x="500" y="978">Нет → сверяем права, URL, проектные условия и раздел.</text>
|
||||||
|
<path class="arrow" d="M500 1015 V1085"/>
|
||||||
|
<text class="yes" x="524" y="1061">да</text>
|
||||||
|
|
||||||
|
<rect class="failure" x="100" y="1100" width="800" height="140" rx="26"/>
|
||||||
|
<text class="box-title" x="500" y="1155">5. Только теперь проверяем кеш и индекс</text>
|
||||||
|
<text class="box-copy" x="500" y="1203">Данные уже доказанно корректны — ищем задержку слоя выдачи.</text>
|
||||||
|
<path class="arrow" d="M500 1240 V1310"/>
|
||||||
|
|
||||||
|
<rect class="success" x="100" y="1325" width="800" height="130" rx="26"/>
|
||||||
|
<text class="box-title" x="500" y="1380">Пользователь видит нужную карточку</text>
|
||||||
|
<text class="box-copy" x="500" y="1428">И причина зафиксирована, чтобы не лечить её наугад снова.</text>
|
||||||
|
|
||||||
|
<text class="no" x="500" y="1510">«Нет» на любом шаге — исправляем именно этот слой.</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,84 @@
|
|||||||
|
import { access, readFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const webRoot = join(fileURLToPath(new URL('..', import.meta.url)));
|
||||||
|
const articlesPath = join(webRoot, 'data', 'articles.json');
|
||||||
|
const slugs = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (slugs.length === 0) {
|
||||||
|
throw new Error('Usage: node scripts/audit-quality-batch.mjs <article-slug> [...slug]');
|
||||||
|
}
|
||||||
|
|
||||||
|
const archive = JSON.parse(await readFile(articlesPath, 'utf8'));
|
||||||
|
const genericPhrases = [
|
||||||
|
'У этой модели нет магической силы',
|
||||||
|
'Материалы для проверки',
|
||||||
|
'Если держать этот порядок, решение остаётся понятным',
|
||||||
|
];
|
||||||
|
let failed = false;
|
||||||
|
|
||||||
|
function count(content, expression) {
|
||||||
|
return (content.match(expression) || []).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plainText(content) {
|
||||||
|
return content
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/&(?:quot|amp|lt|gt|#039);/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const slug of slugs) {
|
||||||
|
const article = archive.find((candidate) => candidate.slug === slug);
|
||||||
|
const issues = [];
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
console.error('FAIL ' + slug + ': статья не найдена');
|
||||||
|
failed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = article.contentHtml;
|
||||||
|
const text = plainText(content);
|
||||||
|
const imageSources = [...content.matchAll(/<img[^>]+src="([^"]+)"/g)].map((match) => match[1]);
|
||||||
|
|
||||||
|
if (article.readingMinutes < 8) issues.push('указано меньше 8 минут чтения');
|
||||||
|
if (text.length < 4800) issues.push('меньше 4800 символов осмысленного текста');
|
||||||
|
if (count(content, /<h2>/g) < 5) issues.push('меньше пяти смысловых разделов');
|
||||||
|
if (count(content, /<figure>/g) < 1 || imageSources.length < 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, /<pre><code>/g) < 1) issues.push('нет воспроизводимого примера');
|
||||||
|
if (count(content, /<a href="https?:\/\//g) < 2) issues.push('меньше двух внешних источников');
|
||||||
|
if (!content.includes('<h2>Проверяемые источники</h2>')) issues.push('нет отдельного раздела с источниками');
|
||||||
|
if (content.includes('undefined') || content.includes('[object Object]')) issues.push('в тексте есть след генерации');
|
||||||
|
|
||||||
|
for (const phrase of genericPhrases) {
|
||||||
|
if (content.includes(phrase)) issues.push('обнаружен шаблонный оборот: «' + phrase + '»');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const source of imageSources.filter((value) => value.startsWith('/'))) {
|
||||||
|
try {
|
||||||
|
await access(join(webRoot, 'public', source));
|
||||||
|
} catch {
|
||||||
|
issues.push('не найден локальный visual asset: ' + source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issues.length > 0) {
|
||||||
|
failed = true;
|
||||||
|
console.error('FAIL ' + slug + ': ' + issues.join('; '));
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
'PASS ' + slug
|
||||||
|
+ ': ' + text.length + ' chars, '
|
||||||
|
+ count(content, /<figure>/g) + ' figure, '
|
||||||
|
+ count(content, /<table>/g) + ' table, '
|
||||||
|
+ count(content, /<pre><code>/g) + ' code example',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed) process.exitCode = 1;
|
||||||
@@ -2,6 +2,11 @@ import { readFile, writeFile } from 'node:fs/promises';
|
|||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
'The bootstrap archive generator is retired: it would overwrite reviewed editorial articles. '
|
||||||
|
+ 'Use a reviewed month-specific upgrade and apply the resulting patch instead.',
|
||||||
|
);
|
||||||
|
|
||||||
const webRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
const webRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const articlesPath = join(webRoot, 'data', 'articles.json');
|
const articlesPath = join(webRoot, 'data', 'articles.json');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const bitrixAdd = {
|
||||||
|
title: 'CIBlockElement::Add',
|
||||||
|
url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/add.php?print=Y',
|
||||||
|
note: 'контракт метода, обработчики до и после записи, ID и LAST_ERROR',
|
||||||
|
};
|
||||||
|
|
||||||
|
const bitrixProperties = {
|
||||||
|
title: 'CIBlockElement::SetPropertyValuesEx',
|
||||||
|
url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/setpropertyvaluesex.php',
|
||||||
|
note: 'точечное сохранение свойств и особенности пустых значений',
|
||||||
|
};
|
||||||
|
|
||||||
|
const bitrixBeforeAdd = {
|
||||||
|
title: 'OnBeforeIBlockElementAdd',
|
||||||
|
url: 'https://dev.1c-bitrix.ru/api_help/iblock/events/onbeforeiblockelementadd.php',
|
||||||
|
note: 'как обработчик может изменить поля или отменить запись',
|
||||||
|
};
|
||||||
|
|
||||||
|
const bitrixGetList = {
|
||||||
|
title: 'CIBlockElement::GetList',
|
||||||
|
url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/getlist.php',
|
||||||
|
note: 'фильтры ACTIVE, ACTIVE_DATE и выборка полей элемента',
|
||||||
|
};
|
||||||
|
|
||||||
|
const catalogProduct = {
|
||||||
|
title: 'CCatalogProduct::Add и актуальная модель Catalog',
|
||||||
|
url: 'https://dev.1c-bitrix.ru/api_help/catalog/classes/ccatalogproduct/add.php',
|
||||||
|
note: 'параметры товарного элемента и версия API',
|
||||||
|
};
|
||||||
|
|
||||||
|
const practiceArticle = {
|
||||||
|
slug: 'editorial-2018-01-practice-bitrix-elements',
|
||||||
|
title: 'Bitrix API. Создаём элемент инфоблока так, чтобы ошибка не исчезла',
|
||||||
|
categories: ['Bitrix', 'PHP', 'Практика'],
|
||||||
|
cover: '/assets/editorial/2018/bitrix-catalog-workflow.png',
|
||||||
|
excerpt: 'Разбираем создание элемента инфоблока как полноценную операцию: контракт полей, обработка LAST_ERROR, свойства, контрольная выборка и проверка публичного сценария.',
|
||||||
|
readingMinutes: 9,
|
||||||
|
contentHtml: [
|
||||||
|
paragraph('Иногда задача формулируется очень просто: «добавь товар через API». Первая версия обычно занимает десять строк — создаём <code>CIBlockElement</code>, вызываем <code>Add</code>, получаем ID. А через день приходит сообщение: товар есть в админке, но карточка пустая, ссылка ведёт не туда или импорт тихо пропустил половину ошибок. Давайте сразу сделаем операцию так, чтобы её можно было проверить, повторить и поддерживать.'),
|
||||||
|
heading('Ситуация: ID — это ещё не готовый результат'),
|
||||||
|
paragraph('Элемент инфоблока — лишь одна часть пользовательского сценария. Для каталога могут быть важны символьный код, раздел, обязательные свойства, активность, картинка, цена и остаток. Метод <code>CIBlockElement::Add</code> действительно возвращает ID при успехе и <code>false</code> при ошибке, а текст причины лежит в <code>LAST_ERROR</code>. Поэтому нормальный критерий готовности состоит из двух вопросов: запись создана и потребитель этой записи видит ожидаемые данные.'),
|
||||||
|
figure('/assets/editorial/2018/bitrix-catalog-workflow.png', 'Разработчик проверяет путь от формы к карточке товара и фиксирует схему процесса', 'Не начинаем с большого импорта. Сначала рисуем путь данных и называем контрольные точки.'),
|
||||||
|
heading('Сначала формулируем контракт операции'),
|
||||||
|
paragraph('Перед вызовом API полезно выписать, какие поля обязательны именно для нашего инфоблока. Это выглядит занудно до первой ошибки импорта, а после неё экономит часы. Не нужно создавать универсальный валидатор Bitrix: достаточно проверить входные данные, зафиксировать системные значения и вернуть диагностируемую ошибку вызывающему коду.'),
|
||||||
|
dataTable(
|
||||||
|
['Участок', 'Что фиксируем', 'Чем доказываем'],
|
||||||
|
[
|
||||||
|
['Вход', '<code>name</code>, внешний ID, категория, файлы', 'Валидация до вызова Bitrix и понятная ошибка для вызывающего кода'],
|
||||||
|
['Элемент', '<code>IBLOCK_ID</code>, <code>NAME</code>, <code>CODE</code>, <code>ACTIVE</code>', 'Массив <code>$fields</code> можно залогировать без секретов'],
|
||||||
|
['Свойства', 'Какие свойства обязательны при первом сохранении', 'Они передаются в <code>PROPERTY_VALUES</code> или проверяются отдельно'],
|
||||||
|
['Результат', 'ID, URL, видимость в нужной выборке', 'Контрольный запрос и тест пользовательского сценария'],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
heading('Рабочий пример'),
|
||||||
|
paragraph('Ниже пример для черновика товара. Я намеренно сохраняю элемент неактивным: пока импорт не завершил все обязательные действия, пользователю незачем видеть полуготовую карточку. Конкретные коды свойств и ID инфоблока должны быть вынесены в конфигурацию проекта, а не спрятаны в середине функции.'),
|
||||||
|
codeBlock([
|
||||||
|
'<?php',
|
||||||
|
'',
|
||||||
|
'use Bitrix\\Main\\Loader;',
|
||||||
|
'',
|
||||||
|
'const PRODUCT_IBLOCK_ID = 12;',
|
||||||
|
'',
|
||||||
|
'function createProductDraft(array $input): int',
|
||||||
|
'{',
|
||||||
|
' if (!Loader::includeModule("iblock")) {',
|
||||||
|
' throw new RuntimeException("Модуль iblock не подключён");',
|
||||||
|
' }',
|
||||||
|
'',
|
||||||
|
' $name = trim((string)($input["name"] ?? ""));',
|
||||||
|
' $code = trim((string)($input["code"] ?? ""));',
|
||||||
|
'',
|
||||||
|
' if ($name === "" || $code === "") {',
|
||||||
|
' throw new InvalidArgumentException("Нужны NAME и CODE");',
|
||||||
|
' }',
|
||||||
|
'',
|
||||||
|
' $element = new CIBlockElement();',
|
||||||
|
' $id = $element->Add([',
|
||||||
|
' "IBLOCK_ID" => PRODUCT_IBLOCK_ID,',
|
||||||
|
' "NAME" => $name,',
|
||||||
|
' "CODE" => $code,',
|
||||||
|
' "ACTIVE" => "N",',
|
||||||
|
' "PROPERTY_VALUES" => [',
|
||||||
|
' "EXTERNAL_ID" => (string)($input["externalId"] ?? ""),',
|
||||||
|
' "BRAND" => (int)($input["brandId"] ?? 0),',
|
||||||
|
' ],',
|
||||||
|
' ]);',
|
||||||
|
'',
|
||||||
|
' if ($id === false) {',
|
||||||
|
' throw new RuntimeException($element->LAST_ERROR ?: "Не удалось создать элемент");',
|
||||||
|
' }',
|
||||||
|
'',
|
||||||
|
' return (int)$id;',
|
||||||
|
'}',
|
||||||
|
]),
|
||||||
|
heading('Почему свойства лучше не «доклеивать» вслепую'),
|
||||||
|
paragraph('Для обязательных свойств, без которых объект не имеет смысла, удобнее передавать <code>PROPERTY_VALUES</code> в том же вызове <code>Add</code>. Метод <code>SetPropertyValuesEx</code> полезен, когда нужно сознательно обновить небольшую часть свойств: он не требует передавать полный набор и экономнее по запросам. Но он возвращает <code>null</code>, поэтому его нельзя использовать как удобный индикатор успеха. Если частичное обновление критично, его надо окружить собственным журналированием и контрольным чтением.'),
|
||||||
|
codeBlock([
|
||||||
|
'<?php',
|
||||||
|
'',
|
||||||
|
'// Осознанное точечное изменение, а не «попробуем и забудем».',
|
||||||
|
'CIBlockElement::SetPropertyValuesEx(',
|
||||||
|
' $elementId,',
|
||||||
|
' PRODUCT_IBLOCK_ID,',
|
||||||
|
' ["SYNC_STATUS" => "ready"]',
|
||||||
|
');',
|
||||||
|
'',
|
||||||
|
'// После важного изменения читаем нужное свойство в контрольном сценарии.',
|
||||||
|
]),
|
||||||
|
heading('Четыре проверки после Add'),
|
||||||
|
orderedList([
|
||||||
|
'Проверяем, что вернулся положительный ID; при <code>false</code> сохраняем <code>LAST_ERROR</code>, входной внешний идентификатор и контекст операции.',
|
||||||
|
'Читаем элемент в том же инфоблоке и убеждаемся, что поля <code>NAME</code>, <code>CODE</code> и нужные свойства действительно сохранены.',
|
||||||
|
'Проверяем публичную выборку с теми же фильтрами, которые использует компонент каталога: активность, даты, раздел, права, цена и остатки — если они участвуют в сценарии.',
|
||||||
|
'Только после этого включаем элемент или помечаем импортированную запись как готовую.',
|
||||||
|
]),
|
||||||
|
heading('Чего я бы не делал'),
|
||||||
|
bulletList([
|
||||||
|
'Не игнорировал бы результат <code>Add</code> в надежде, что ошибка «сама попадёт в журнал».',
|
||||||
|
'Не делал бы элемент активным до заполнения зависимых данных.',
|
||||||
|
'Не генерировал бы <code>CODE</code> без правила уникальности: два одинаковых названия неизбежно встретятся.',
|
||||||
|
'Не очищал бы весь кеш первым действием. Сначала нужно доказать, что проблема именно в кеше, а не в данных или фильтре.',
|
||||||
|
]),
|
||||||
|
heading('Проверяемые источники'),
|
||||||
|
sourceList([bitrixAdd, bitrixProperties, bitrixGetList]),
|
||||||
|
heading('Итог'),
|
||||||
|
paragraph('Сам вызов <code>CIBlockElement::Add</code> несложен. Сложность в том, чтобы не потерять границу между «запись появилась» и «сценарий закончен». Если хранить контракт полей рядом с кодом, проверять <code>LAST_ERROR</code> и делать контрольную выборку, импорт перестаёт быть магией. А дальше уже можно спокойно добавлять цены, остатки и любые проектные правила.'),
|
||||||
|
].join('\n'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mechanismArticle = {
|
||||||
|
slug: 'editorial-2018-01-mechanism-bitrix-elements',
|
||||||
|
title: 'Bitrix API. Что на самом деле происходит вокруг CIBlockElement::Add',
|
||||||
|
categories: ['Bitrix', 'PHP', 'Архитектура'],
|
||||||
|
cover: '/assets/editorial/2018/bitrix-add-lifecycle.svg',
|
||||||
|
excerpt: 'Разбираем жизненный цикл добавления элемента: кто проверяет поля, где срабатывают события Bitrix, почему глобальный обработчик не заменяет сервис и как тестировать эту границу.',
|
||||||
|
readingMinutes: 9,
|
||||||
|
contentHtml: [
|
||||||
|
paragraph('Когда Bitrix-проект разрастается, вокруг простого <code>CIBlockElement::Add</code> появляется невидимый код: обработчики событий, правила символьного кода, импортеры, каталог, поиск и шаблоны. Из-за этого одинаковый вызов сегодня работает из формы, а завтра падает из консольного скрипта. Давайте разложим путь записи по шагам и не будем прятать бизнес-правило в месте, где его трудно обнаружить.'),
|
||||||
|
heading('Карта жизненного цикла'),
|
||||||
|
paragraph('Документация Bitrix говорит важную вещь: перед добавлением вызывается <code>OnBeforeIBlockElementAdd</code>. Обработчик получает поля по ссылке, поэтому способен их изменить; чтобы отменить запись, он должен установить исключение через <code>$APPLICATION->ThrowException()</code> и вернуть <code>false</code>. После успешной записи срабатывают события после добавления. Это значит, что обработчик — реальная часть контракта метода, а не декоративная «магия в init.php».'),
|
||||||
|
figure('/assets/editorial/2018/bitrix-add-lifecycle.svg', 'Последовательность от формы до контрольной публичной выборки при создании элемента Bitrix', 'ID возвращается из слоя инфоблока, но качество результата подтверждается уже в пользовательском сценарии.'),
|
||||||
|
heading('Где живёт каждое правило'),
|
||||||
|
dataTable(
|
||||||
|
['Место', 'Хорошая ответственность', 'Что туда не стоит класть'],
|
||||||
|
[
|
||||||
|
['Сервис создания', 'Проверка входа, подготовка полей, перевод ошибки в понятный результат', 'Глобальные побочные эффекты для любого инфоблока'],
|
||||||
|
['OnBeforeIBlockElementAdd', 'Последний общий барьер: запрет пустого CODE, аудит общей политики', 'Внешние HTTP-вызовы, тяжёлую обработку файлов, правила одного экрана'],
|
||||||
|
['После записи', 'Отправка события, фоновая реакция, журналирование успешной операции', 'Изменение результата, от которого зависит успех текущего Add'],
|
||||||
|
['Публичный компонент', 'Фильтрация и отображение данных', 'Исправление отсутствующих обязательных данных «на лету»'],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
heading('Минимальный предохранитель в событии'),
|
||||||
|
paragraph('Ниже — не замена сервису, а общий барьер для конкретного инфоблока. Он предотвращает запись элемента без символьного кода независимо от того, откуда пришёл вызов: админка, импорт или самописный endpoint. Важно, что код не пытается угадать всё бизнес-правило товара. Он проверяет только инвариант, который действительно должен быть общим.'),
|
||||||
|
codeBlock([
|
||||||
|
'<?php',
|
||||||
|
'',
|
||||||
|
'const PRODUCT_IBLOCK_ID = 12;',
|
||||||
|
'',
|
||||||
|
'AddEventHandler(',
|
||||||
|
' "iblock",',
|
||||||
|
' "OnBeforeIBlockElementAdd",',
|
||||||
|
' ["CatalogElementGuard", "beforeAdd"]',
|
||||||
|
');',
|
||||||
|
'',
|
||||||
|
'final class CatalogElementGuard',
|
||||||
|
'{',
|
||||||
|
' public static function beforeAdd(array &$fields): bool',
|
||||||
|
' {',
|
||||||
|
' if ((int)($fields["IBLOCK_ID"] ?? 0) !== PRODUCT_IBLOCK_ID) {',
|
||||||
|
' return true;',
|
||||||
|
' }',
|
||||||
|
'',
|
||||||
|
' if (trim((string)($fields["CODE"] ?? "")) === "") {',
|
||||||
|
' global $APPLICATION;',
|
||||||
|
' $APPLICATION->ThrowException("Для товара нужен символьный код");',
|
||||||
|
' return false;',
|
||||||
|
' }',
|
||||||
|
'',
|
||||||
|
' return true;',
|
||||||
|
' }',
|
||||||
|
'}',
|
||||||
|
]),
|
||||||
|
heading('Почему событие не должно быть единственным валидатором'),
|
||||||
|
paragraph('Потому что событие не знает намерения конкретной операции. Один экран может создавать черновик без картинки, другой — импортировать поставщика, третий — мигрировать старые записи. Если все проверки спрятать в <code>OnBeforeIBlockElementAdd</code>, получится глобальная функция с десятком условий и неожиданными побочными эффектами. Сервис создания должен объяснять, почему он принимает или отклоняет вход. Событие лишь страхует инвариант, который действует для всех.'),
|
||||||
|
heading('Сервис остаётся точкой диагностики'),
|
||||||
|
codeBlock([
|
||||||
|
'<?php',
|
||||||
|
'',
|
||||||
|
'function addCatalogElement(array $fields): int',
|
||||||
|
'{',
|
||||||
|
' if (!\\Bitrix\\Main\\Loader::includeModule("iblock")) {',
|
||||||
|
' throw new RuntimeException("Модуль iblock не подключён");',
|
||||||
|
' }',
|
||||||
|
'',
|
||||||
|
' $element = new CIBlockElement();',
|
||||||
|
' $id = $element->Add($fields);',
|
||||||
|
'',
|
||||||
|
' if ($id === false) {',
|
||||||
|
' $message = $element->LAST_ERROR ?: "Bitrix не вернул причину ошибки";',
|
||||||
|
' throw new RuntimeException($message);',
|
||||||
|
' }',
|
||||||
|
'',
|
||||||
|
' return (int)$id;',
|
||||||
|
'}',
|
||||||
|
]),
|
||||||
|
heading('После записи — это уже другой разговор'),
|
||||||
|
paragraph('Обработчик после добавления удобен для журналирования, запуска поиска или отправки внутреннего уведомления. Но он не должен молча решать судьбу уже созданного элемента. Если побочный шаг упал после того, как <code>Add</code> вернул ID, повторный вызов <code>Add</code> из обработчика легко создаст дубль, а внешний сервис получит два одинаковых запроса. Поэтому после записи я бы сохранял ID, внешний ключ и понятный статус операции, а ошибку реакции разбирал как отдельную задачу.'),
|
||||||
|
paragraph('Если синхронизация с внешней системой действительно обязательна для публикации товара, полезно разделить два состояния: «элемент сохранён» и «элемент готов для пользователя». Первый факт подтверждает сервис создания, второй — контрольная проверка после всех зависимых действий. Тогда временный сбой индексации или уведомления не превращается в неясную историю, где никто не понимает, можно ли безопасно повторить импорт.'),
|
||||||
|
heading('Как тестировать такую связку'),
|
||||||
|
paragraph('В 2018-м легко ограничиться ручной проверкой в админке, но здесь полезно хотя бы зафиксировать короткую матрицу. Она не требует сложного тестового фреймворка: часть сценариев можно выполнить на тестовом инфоблоке и сохранить как чек-лист релиза. Главное — проверять и прямой сервис, и поведение глобального события.'),
|
||||||
|
dataTable(
|
||||||
|
['Сценарий', 'Ожидание', 'Где искать ошибку при сбое'],
|
||||||
|
[
|
||||||
|
['Корректный элемент', 'Сервис возвращает ID, элемент читается', 'Поля сервиса и конфигурация инфоблока'],
|
||||||
|
['Пустой CODE', 'Запись отменена, причина понятна вызывающему коду', 'Обработчик OnBeforeIBlockElementAdd'],
|
||||||
|
['Другой инфоблок', 'Охранник не вмешивается', 'Слишком широкое условие в обработчике'],
|
||||||
|
['Импорт или CLI', 'Результат тот же, что из формы', 'Скрытая зависимость от HTTP-сессии или интерфейса'],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
heading('Проверяемые источники'),
|
||||||
|
sourceList([bitrixAdd, bitrixBeforeAdd, bitrixProperties]),
|
||||||
|
heading('Итог'),
|
||||||
|
paragraph('События Bitrix полезны, когда их граница ясна. Общий инвариант — в обработчик. Намерение операции, логирование и перевод ошибки — в сервис. Публичная видимость — в отдельную проверку после создания. С такой схемой даже старый проект перестаёт выглядеть набором случайных <code>init.php</code>-заклинаний: у каждого правила появляется место и причина.'),
|
||||||
|
].join('\n'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const fieldArticle = {
|
||||||
|
slug: 'editorial-2018-01-field-bitrix-elements',
|
||||||
|
title: 'Bitrix API. Элемент есть в админке, но не виден в каталоге',
|
||||||
|
categories: ['Bitrix', 'PHP', 'Диагностика'],
|
||||||
|
cover: '/assets/editorial/2018/bitrix-visibility-diagnostic.svg',
|
||||||
|
excerpt: 'Полевой разбор частой ошибки Bitrix: Add вернул ID, админка показывает элемент, но пользователь не видит его в каталоге. Ищем причину по слоям, а не очищаем кеш наугад.',
|
||||||
|
readingMinutes: 10,
|
||||||
|
contentHtml: [
|
||||||
|
paragraph('Знакомая картина: скрипт вернул ID, в админке новый товар есть, а на сайте его нет. Первый импульс — «почистить кеш». Иногда это действительно помогает, но чаще кеш просто оказывается первым подозреваемым, потому что его легко назвать. Давайте сначала отделим факт записи от публичной видимости и пройдём путь теми же условиями, которыми живёт каталог.'),
|
||||||
|
heading('Постановка проблемы'),
|
||||||
|
paragraph('Админка и публичный компонент редко показывают одинаковую выборку. Админка может отобразить неактивный элемент, а каталог фильтрует по <code>ACTIVE</code>, датам активности, разделу, правам, цене, наличию и проектным свойствам. Поэтому вопрос «почему элемент не виден?» нельзя решать одной командой. Нужен короткий список слоёв и доказательство на каждом.'),
|
||||||
|
figure('/assets/editorial/2018/bitrix-visibility-diagnostic.svg', 'Дерево диагностики: от результата Add к условиям публичного каталога', 'Начинаем не с кеша, а с самого раннего условия, которое может исключить элемент из публичной выборки.'),
|
||||||
|
heading('Проверяем по слоям'),
|
||||||
|
dataTable(
|
||||||
|
['Слой', 'Что проверяем', 'Как получить доказательство'],
|
||||||
|
[
|
||||||
|
['Запись', '<code>Add</code> вернул ID, <code>LAST_ERROR</code> пуст', 'Лог результата и внешний ID операции'],
|
||||||
|
['Инфоблок', '<code>ACTIVE</code>, даты, символьный код, раздел', 'Контрольная выборка с теми же базовыми фильтрами'],
|
||||||
|
['Свойства', 'Обязательная связь, SKU, картинка, проектные флаги', 'Чтение конкретных свойств для созданного ID'],
|
||||||
|
['Каталог', 'Цена, остаток, доступность — если компонент их требует', 'Проверка конфигурации каталога и товарных параметров'],
|
||||||
|
['Публичный путь', 'Фильтр компонента, права, кеш и индекс', 'Повтор сценария от имени нужного пользователя'],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
heading('Контрольный запрос вместо догадки'),
|
||||||
|
paragraph('Документация <code>CIBlockElement::GetList</code> описывает фильтры <code>ACTIVE</code>, <code>ACTIVE_DATE</code> и выбор нужных полей. Ниже не универсальный каталоговый запрос, а диагностическая проба. Она отвечает на первый важный вопрос: проходит ли наш элемент хотя бы базовые условия публичной выдачи. Если нет — проблему надо искать в данных, а не в шаблоне.'),
|
||||||
|
codeBlock([
|
||||||
|
'<?php',
|
||||||
|
'',
|
||||||
|
'const PRODUCT_IBLOCK_ID = 12;',
|
||||||
|
'',
|
||||||
|
'$result = CIBlockElement::GetList(',
|
||||||
|
' [],',
|
||||||
|
' [',
|
||||||
|
' "IBLOCK_ID" => PRODUCT_IBLOCK_ID,',
|
||||||
|
' "=ID" => $elementId,',
|
||||||
|
' "ACTIVE" => "Y",',
|
||||||
|
' "ACTIVE_DATE" => "Y",',
|
||||||
|
' ],',
|
||||||
|
' false,',
|
||||||
|
' ["nTopCount" => 1],',
|
||||||
|
' ["ID", "IBLOCK_ID", "NAME", "CODE", "ACTIVE", "DATE_ACTIVE_FROM", "DATE_ACTIVE_TO"]',
|
||||||
|
');',
|
||||||
|
'',
|
||||||
|
'$row = $result->Fetch();',
|
||||||
|
'if ($row === false) {',
|
||||||
|
' throw new RuntimeException("Элемент не проходит базовый публичный фильтр");',
|
||||||
|
'}',
|
||||||
|
]),
|
||||||
|
heading('Где здесь каталог'),
|
||||||
|
paragraph('Элемент инфоблока и товарная часть каталога — соседние, но разные уровни. Если публичный компонент требует цену, остаток или связь торгового предложения с товаром, одного <code>CIBlockElement::Add</code> недостаточно. Документация каталога отдельно описывает товарные параметры; в старом коде можно встретить <code>CCatalogProduct::Add</code>, но текущая документация помечает его устаревшим и рекомендует модель <code>\\Bitrix\\Catalog\\Model\\Product</code>. Для исторического проекта это не повод переписывать всё за вечер, а повод явно зафиксировать используемую версию API и не смешивать создание элемента с догадкой о его товарном состоянии.'),
|
||||||
|
heading('Мини-матрица симптомов'),
|
||||||
|
dataTable(
|
||||||
|
['Симптом', 'Самая частая причина', 'Безопасное следующее действие'],
|
||||||
|
[
|
||||||
|
['Нет ID', 'Ошибка обязательного поля, свойства или прав', 'Вывести <code>LAST_ERROR</code> и входной внешний ID'],
|
||||||
|
['ID есть, базовый GetList пуст', 'ACTIVE, дата, инфоблок или неверный ID', 'Сначала читать поля элемента без публичных фильтров'],
|
||||||
|
['GetList есть, карточки нет', 'Дополнительный фильтр компонента, раздел, права, URL', 'Сравнить фильтр и маршрут компонента с контрольной выборкой'],
|
||||||
|
['Карточка есть, нельзя купить', 'Не настроены параметры каталога, цена или остаток', 'Проверить товарный слой отдельно от инфоблока'],
|
||||||
|
['После изменения появляется не сразу', 'Кеш или индекс', 'Подтвердить корректность данных и только затем адресно обновлять кеш/индекс'],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
heading('Почему не стоит начинать с очистки кеша'),
|
||||||
|
paragraph('Потому что очистка кеша скрывает различие между двумя ситуациями: данные корректны, но слой кеширования устарел; или данные с самого начала не удовлетворяют фильтру. В первом случае нужна адресная стратегия инвалидирования. Во втором — очистка не решит проблему, а только добавит шума. Хорошая диагностика оставляет после себя не только исправленный товар, но и понимание, какое условие не было выполнено.'),
|
||||||
|
heading('Чек-лист перед закрытием задачи'),
|
||||||
|
orderedList([
|
||||||
|
'Зафиксировать ID созданного элемента и внешний идентификатор операции.',
|
||||||
|
'Считать элемент без публичных ограничений и проверить, что ожидаемые поля и свойства сохранены.',
|
||||||
|
'Повторить контрольную выборку с <code>ACTIVE</code> и <code>ACTIVE_DATE</code>.',
|
||||||
|
'Проверить условия конкретного компонента: раздел, права, проектные фильтры, URL.',
|
||||||
|
'Если это товар — отдельно проверить цену, остаток и доступность, не смешивая этот слой с данными инфоблока.',
|
||||||
|
'Только после этого проверять кеш и индекс; зафиксировать, какое именно действие обновляет их в данном проекте.',
|
||||||
|
]),
|
||||||
|
heading('Проверяемые источники'),
|
||||||
|
sourceList([bitrixAdd, bitrixGetList, catalogProduct]),
|
||||||
|
heading('Итог'),
|
||||||
|
paragraph('Фраза «элемент есть в админке» говорит только о том, что одна запись сохранилась. Для каталога этого недостаточно. Если идти от ID к базовой выборке, от неё к товарному слою и только затем к кешу, причина обычно находится быстро. И самое приятное: на следующей похожей задаче уже не нужно вспоминать магическую кнопку очистки — есть нормальный порядок проверки.'),
|
||||||
|
].join('\n'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const revisions = [practiceArticle, mechanismArticle, fieldArticle];
|
||||||
|
const archive = JSON.parse(await readFile(articlesPath, 'utf8'));
|
||||||
|
const revisionBySlug = new Map(revisions.map((article) => [article.slug, article]));
|
||||||
|
|
||||||
|
for (const revision of revisions) {
|
||||||
|
if (!archive.some((article) => article.slug === revision.slug)) {
|
||||||
|
throw new Error('Article not found: ' + revision.slug);
|
||||||
|
}
|
||||||
|
if (!revision.contentHtml.includes('<figure>') || !revision.contentHtml.includes('<table>')) {
|
||||||
|
throw new Error('Visual or table missing: ' + revision.slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = archive.map((article) => {
|
||||||
|
const revision = revisionBySlug.get(article.slug);
|
||||||
|
return revision ? { ...article, ...revision } : article;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.argv.includes('--print-revisions')) {
|
||||||
|
console.log(JSON.stringify(revisions, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log('Usage: node web/scripts/upgrade-2018-01.mjs --print-revisions');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user