revise late 2018 editorial articles
Build and deploy / deploy (push) Successful in 14s

This commit is contained in:
2026-07-31 10:09:53 +03:00
parent 2c7f9d37d7
commit c72a72b8f3
18 changed files with 2281 additions and 6 deletions
+551
View File
@@ -0,0 +1,551 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.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 = 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 sourceList(items) {
return '<ul>' + items.map(({ title, url, note }) => (
'<li><a href="' + url + '" target="_blank" rel="noopener noreferrer">' + title + '</a> — ' + 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');
const requiredFragments = [
'<figure>',
'<figcaption>',
'<table>',
'<thead>',
'<pre><code>',
'<ol>',
'<h2>Проверяемые источники</h2>',
];
for (const fragment of requiredFragments) {
if (!contentHtml.includes(fragment)) {
throw new Error(meta.slug + ': missing required fragment ' + fragment);
}
}
if ((contentHtml.match(/<h2>/g) || []).length < 6) {
throw new Error(meta.slug + ': fewer than six sections');
}
if (sources.length < 2) {
throw new Error(meta.slug + ': at least two primary sources are required');
}
return { ...meta, contentHtml, bodyLength };
}
const bitrixFileInput = {
title: '1С-Битрикс: FileInput',
url: 'https://dev.1c-bitrix.ru/api_d7/bitrix/main/ui/fileinput/index.php',
note: 'описание контрола, его методов createInstance, prepareFile и show, а также параметров загрузки',
};
const bitrixSaveFile = {
title: '1С-Битрикс: CFile::SaveFile',
url: 'https://dev.1c-bitrix.ru/api_help/main/reference/cfile/savefile.php?print=Y',
note: 'метод сохраняет файловый массив и регистрирует его в b_file, возвращая числовой идентификатор',
};
const bitrixMakeFileArray = {
title: '1С-Битрикс: CFile::MakeFileArray',
url: 'https://dev.1c-bitrix.ru/api_help/main/reference/cfile/makefilearray.php?print=Y',
note: 'формирует файловый массив, в том числе по ID существующего файла',
};
const bitrixUpdate = {
title: '1С-Битрикс: CIBlockElement::Update',
url: 'https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/update.php?print=Y',
note: 'Update принимает массив полей, возвращает true или false, а текст ошибки остаётся в LAST_ERROR',
};
const phpFiles = {
title: 'PHP Manual: $_FILES',
url: 'https://www.php.net/manual/en/reserved.variables.files.php',
note: 'структура данных, переданных HTTP POST с файлом',
};
const phpUploadedFile = {
title: 'PHP Manual: move_uploaded_file',
url: 'https://www.php.net/manual/en/function.move-uploaded-file.php',
note: 'функция работает только с файлом, который PHP признал HTTP POST upload; источник нужен для границы временного файла',
};
const jqueryOn = {
title: 'jQuery API: .on()',
url: 'https://api.jquery.com/on/',
note: 'делегированный обработчик работает на потомках существующего контейнера и может быть привязан с namespace',
};
const jqueryReady = {
title: 'jQuery API: .ready()',
url: 'https://api.jquery.com/ready/',
note: 'обработчик запускается, когда DOM готов к безопасному изменению',
};
const webpackShimming = {
title: 'webpack: Shimming',
url: 'https://webpack.js.org/guides/shimming/',
note: 'Webpack понимает модули, но старые библиотеки могут ожидать глобальные зависимости; globals следует оставлять только для нужной совместимости',
};
const webpackProvide = {
title: 'webpack: ProvidePlugin',
url: 'https://webpack.js.org/plugins/provide-plugin/',
note: 'ProvidePlugin подставляет модуль для свободного идентификатора в скомпилированном модуле; отдельно показано сопоставление window.jQuery',
};
const webpackV4Migration = {
title: 'webpack: migration to v4',
url: 'https://webpack.js.org/migrate/4/',
note: 'в webpack 4 CommonsChunkPlugin заменён настройкой optimization.splitChunks',
};
const webpackSplitChunks = {
title: 'webpack: SplitChunksPlugin',
url: 'https://webpack.js.org/plugins/split-chunks-plugin/',
note: 'в webpack 4 общие модули извлекаются правилами splitChunks, что меняет состав начальных ассетов',
};
const imageOwnershipArticle = createRevision(
{
slug: 'editorial-2018-10-mechanism-image-workflow',
title: 'Bitrix API. Форма с изображением: где заканчивается редактор и начинается файл',
categories: ['Bitrix', 'PHP', 'Формы'],
cover: '/assets/editorial/2018/bitrix-file-state-ownership-2018.svg',
excerpt: 'Картинка появляется в форме, но после сохранения карточка остаётся прежней. Разбираем четыре состояния файла и ставим проверку на границе между контролом, b_file и элементом инфоблока.',
readingMinutes: 10,
},
[
paragraph('В форме картинка уже видна, а после сохранения у товара остаётся старая обложка. Цена ошибки не только в пустом поле: оператор уверен, что обновил карточку, а каталог продолжает показывать не тот товар.'),
paragraph('В октябрьском проекте я бы не начинал с повторного вызова редактора. Сначала разделил бы четыре состояния: выбор файла в браузере, данные формы, запись файла в Bitrix и ссылка на неё в элементе инфоблока. Пока они названы одним словом «картинка», причина прячется между двумя успешными шагами.'),
heading('Один экран формы не означает один объект'),
paragraph('Контрол <code>\\Bitrix\\Main\\UI\\FileInput</code> формирует интерфейс выбора и загрузки. Его <code>show()</code> возвращает разметку и JavaScript для страницы, но сам показ контрола не доказывает, что файл уже связан с нужным элементом. Официальная документация отдельно называет <code>prepareFile()</code> как способ получить файловый массив для дальнейшей обработки. Значит, после интерфейса всё равно остаётся серверный путь.'),
paragraph('Для диагностики я записываю не красивый preview, а идентификаторы и границы. У выбранного в браузере файла нет постоянного Bitrix ID. У строки из <code>b_file</code> уже есть ID, но она может быть ни с чем не связана. У поля <code>PREVIEW_PICTURE</code> элемента есть отдельное состояние, и оно изменится только после успешного <code>CIBlockElement::Update()</code>.'),
dataTable(
['Состояние', 'Кто им владеет', 'Что считаю доказательством', 'Следующий шаг'],
[
['Файл выбран в диалоге', 'браузер и DOM формы', 'видно имя, размер или preview до отправки', 'не считать это сохранением'],
['Файловый массив в запросе', 'PHP-обработчик', '<code>$_FILES</code> содержит ожидаемое поле и <code>UPLOAD_ERR_OK</code>', 'проверить лимит и передать в Bitrix'],
['Файл зарегистрирован', 'таблица <code>b_file</code>', '<code>CFile::SaveFile()</code> вернул положительный ID', 'получить файл по ID и сохранить связь'],
['Изображение карточки изменено', 'элемент инфоблока', '<code>CIBlockElement::Update()</code> вернул <code>true</code>', 'запросить элемент заново и открыть карточку'],
],
),
figure(
'/assets/editorial/2018/bitrix-file-state-ownership-2018.svg',
'Карта состояний изображения: браузерный File, поле формы, зарегистрированный CFile ID и PREVIEW_PICTURE элемента инфоблока. Между этапами показаны POST, CFile SaveFile и CIBlockElement Update.',
'Preview нужен пользователю, но подтверждённой считается только связь между ID файла и полем элемента.',
),
heading('Контрол показываю с явными ограничениями'),
paragraph('Ниже не универсальный шаблон, а минимальная точка проверки для формы с одной картинкой. Идентификатор текущего файла приходит из уже сохранённой карточки. Поле формы получает осмысленное имя, а ограничения задаются рядом с контролом. Если на старой установке отсутствует этот класс или часть источников FileInput отключена, сначала проверяю версию модуля main и права пользователя, а не копирую настройки вслепую.'),
codeBlock([
'<?php',
'use Bitrix\\\\Main\\\\UI\\\\FileInput;',
'',
'$currentFileId = (int) $arResult[\'PREVIEW_PICTURE\'];',
'',
'echo FileInput::createInstance(array(',
' \'id\' => \'catalog_preview\',',
' \'name\' => \'CATALOG[PREVIEW_PICTURE]\',',
' \'upload\' => true,',
' \'allowUpload\' => FileInput::UPLOAD_IMAGES,',
' \'medialib\' => false,',
' \'fileDialog\' => true,',
' \'cloud\' => false,',
' \'delete\' => true,',
' \'edit\' => true,',
' \'maxCount\' => 1,',
' \'maxSize\' => 5 * 1024 * 1024,',
'))->show($currentFileId);',
].join('\n')),
paragraph('Значение <code>maxSize</code> помогает пользователю раньше увидеть предел, но не заменяет серверную проверку. DOM может быть создан старым шаблоном, обновлён Ajax-ом или отправлен вручную. Поэтому имя поля и фактический массив, пришедший на сервер, я сверяю в тестовом запросе. В этом месте удобнее увидеть несовпадение <code>CATALOG[PREVIEW_PICTURE]</code> и обработчика, чем позже искать «потерянный» файл.'),
heading('Сохраняю файл до привязки и проверяю оба ответа'),
paragraph('В учебном обработчике ниже файл приходит из обычного multipart-поля. В проекте с FileInput вместо <code>$_FILES</code> может оказаться результат его подготовки, но контракт одинаковый: на входе — файловый массив, на выходе — подтверждённый ID или понятная ошибка. Я не сохраняю путь из браузера и не записываю имя файла как связь с карточкой.'),
codeBlock([
'function saveCatalogImage(array $upload)',
'{',
' if (($upload[\'error\'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {',
' throw new RuntimeException(\'Image upload did not finish\');',
' }',
'',
' if ((int) ($upload[\'size\'] ?? 0) < 1 || (int) $upload[\'size\'] > 5 * 1024 * 1024) {',
' throw new RuntimeException(\'Image size is outside the form limit\');',
' }',
'',
' $upload[\'MODULE_ID\'] = \'catalog\';',
' $fileId = (int) CFile::SaveFile($upload, \'catalog\');',
'',
' if ($fileId < 1 || !CFile::GetFileArray($fileId)) {',
' throw new RuntimeException(\'Bitrix did not register the uploaded file\');',
' }',
'',
' return $fileId;',
'}',
'',
'$fileId = saveCatalogImage($_FILES[\'CATALOG_PREVIEW\']);',
].join('\n')),
paragraph('Метод <code>CFile::SaveFile()</code> регистрирует файл в <code>b_file</code> и возвращает числовой ID. Проверка через <code>CFile::GetFileArray()</code> здесь не украшение: она отделяет случай «обработчик получил форму» от случая «у нас есть объект, на который можно ссылаться». На старом проекте я также сохраняю в закрытый лог ID элемента, ID файла и текст <code>LAST_ERROR</code>, но не складываю в журнал сам файл или персональные поля формы.'),
codeBlock([
'$element = new CIBlockElement();',
'$picture = CFile::MakeFileArray($fileId);',
'',
'$updated = $element->Update($elementId, array(',
' \'PREVIEW_PICTURE\' => $picture,',
'));',
'',
'if (!$updated) {',
' throw new RuntimeException($element->LAST_ERROR);',
'}',
].join('\n')),
paragraph('Для обновления изображения инфоблока нужен файловый массив. <code>CFile::MakeFileArray()</code> умеет собрать его по существующему ID, а <code>CIBlockElement::Update()</code> возвращает <code>false</code> и оставляет текст ошибки в <code>LAST_ERROR</code>. Это две разные проверки; положительный <code>$fileId</code> не делает обновление элемента успешным сам по себе.'),
heading('Проверяю путь в том порядке, в котором он ломается'),
orderedList([
'Открываю карточку с известным текущим ID картинки и отмечаю его до изменения.',
'Выбираю небольшой тестовый JPEG и в браузерной сетевой вкладке сверяю имя поля и ответ отправки формы.',
'На сервере временно фиксирую только код upload-ошибки, ID элемента и ID созданного файла.',
'После <code>Update()</code> повторно читаю <code>PREVIEW_PICTURE</code> у этого же элемента, а не доверяю старому <code>$arResult</code>.',
'Открываю карточку новым запросом в браузере и проверяю, что URL изображения указывает на ожидаемый файл.',
'Только после этого удаляю временную диагностику или оставляю безопасный лог ошибки для следующего случая.',
]),
heading('Не путаю замену с удалением'),
paragraph('Самый неприятный крайний случай — форма отправлена без нового файла. Для одной карточки это может означать «сохранить старую картинку», а флаг удаления означает противоположное. Нельзя получать это решение из пустого preview в DOM: пустой preview может появиться из-за перерисовки формы. Политику формулирую явно: нет нового файла и нет флага удаления — поле остаётся как было; новый файл — заменяем после успешного сохранения; удаление — передаём в Bitrix отдельным согласованным полем.'),
paragraph('Если загрузка и редактирование сделаны в два HTTP-запроса, появляется ещё одна граница. Пользователь может закрыть вкладку после первого запроса. Тогда созданный ID не должен автоматически становиться картинкой чужого элемента. В старой системе достаточно хранить ID в сессии или в черновике, сверять владельца при финальном сохранении и отдельно убирать неиспользованные файлы по согласованному регламенту. Это не повод усложнять маленькую форму очередями; это повод не считать временный файл завершённым результатом.'),
heading('Ограничения примера'),
paragraph('Здесь не задан общий список допустимых MIME-типов и размеров картинки: он зависит от каталога, старой версии Bitrix и требований редакторов. Ограничение в контроле не защищает сервер, поэтому реальные проверки типа, размера, прав и пределов PHP надо добавлять в обработчик. Также не стоит переносить пример в свойство типа файл без проверки формата <code>PROPERTY_VALUES</code>: документация <code>CIBlockElement::Update()</code> отдельно оговаривает работу с файловыми свойствами.'),
heading('Итог'),
paragraph('Редактор отвечает за выбор и preview, <code>CFile</code> — за зарегистрированный файл, а элемент инфоблока — за ссылку на него. Если проверить каждую передачу отдельно, «картинка была в форме» перестаёт быть ложным признаком готовности. В следующей правке достаточно повторить один тестовый POST и сравнить ID файла с <code>PREVIEW_PICTURE</code> после свежего чтения элемента.'),
],
[bitrixFileInput, bitrixSaveFile, bitrixMakeFileArray, bitrixUpdate, phpFiles, phpUploadedFile],
);
const imageFormArticle = createRevision(
{
slug: 'editorial-2018-10-field-image-workflow',
title: 'Bitrix API. Legacy-форма с редактором изображения: разбор типичной ошибки',
categories: ['Bitrix', 'PHP', 'jQuery', 'Формы'],
cover: '/assets/editorial/2018/legacy-photo-form-contract-2018.svg',
excerpt: 'Разбор формы, где preview обновляется, а в карточку попадает старый файл. Фиксируем контракт между jQuery, FileInput и серверным сохранением, чтобы не путать DOM с подтверждённым ID.',
readingMinutes: 11,
},
[
paragraph('Редактор изображения показывает новую фотографию, но после отправки формы карточка снова открывается со старой. Цена ошибки заметна не сразу: менеджер повторяет загрузку, а в файловом хранилище появляются лишние записи без понятной связи с товаром.'),
paragraph('В такой legacy-форме я не пытаюсь заставить jQuery «запомнить картинку». Нужно решить, что именно передаёт форма: новый бинарный файл, ID уже существующего файла или команду удалить старый. Preview — только экранный сигнал. Сервер должен получить одно из этих состояний и вернуть результат, который форма может показать без догадок.'),
heading('Фиксирую контракт формы до правки плагина'),
paragraph('Перед работой я выписываю имена полей и один реальный POST. Старые шаблоны часто держат одновременно обычный <code>input type=file</code>, скрытый <code>PHOTO_ID</code> и HTML редактора. Если после Ajax-перерисовки в документе остаются два поля с одинаковым <code>name</code>, браузер отправит оба, а обработчик выберет не то значение. Поэтому в контракте у каждого поля одна роль.'),
dataTable(
['Поле или сигнал', 'Значение', 'Владелец', 'Что означает на сервере'],
[
['<code>CATALOG_PREVIEW</code>', 'бинарный файл из multipart POST', 'браузер до отправки', 'кандидат на новую картинку'],
['<code>KEEP_PICTURE</code>', '0 или 1', 'форма и сценарий редактирования', 'сохраняем существующую картинку, если нового файла нет'],
['<code>DELETE_PICTURE</code>', '0 или 1', 'явное действие пользователя', 'запрашиваем удаление, не выводим его из пустого DOM'],
['<code>.js-photo-state</code>', 'текст статуса', 'jQuery', 'не попадает в модель данных и не является ID файла'],
],
),
figure(
'/assets/editorial/2018/legacy-photo-form-contract-2018.svg',
'Контракт legacy-формы: выбранный файл живёт в multipart POST, preview и статус принадлежат DOM, а сервер возвращает зарегистрированный ID и результат обновления карточки.',
'У preview нет права менять карточку; его задача — показать пользователю, какой файл выбран до подтверждения сервера.',
),
heading('Не прячу имена полей внутри редактора'),
paragraph('Сначала оставляю контрол и обработчик читаемыми. В примере ниже FileInput рисует интерфейс для одного изображения, а вокруг него есть контейнер для статуса. Если конкретная версия Bitrix возвращает файл через свой формат, я проверяю его отдельным тестовым POST и передаю дальше как файловый массив. Не подменяю это знание строкой из скрытого поля.'),
codeBlock([
'<?php',
'use Bitrix\\\\Main\\\\UI\\\\FileInput;',
'',
'$currentId = (int) $arResult[\'PREVIEW_PICTURE\'];',
'?>',
'<form id="catalog-photo-form" method="post" enctype="multipart/form-data">',
' <div class="js-photo-editor">',
' <?php',
' echo FileInput::createInstance(array(',
' \'id\' => \'catalog_preview\',',
' \'name\' => \'CATALOG_PREVIEW\',',
' \'upload\' => true,',
' \'allowUpload\' => FileInput::UPLOAD_IMAGES,',
' \'maxCount\' => 1,',
' \'maxSize\' => 5 * 1024 * 1024,',
' \'delete\' => true,',
' ))->show($currentId);',
' ?>',
' </div>',
' <label><input type="checkbox" name="KEEP_PICTURE" value="1" checked> оставить текущую картинку</label>',
' <label><input type="checkbox" name="DELETE_PICTURE" value="1"> удалить картинку</label>',
' <p class="js-photo-state" aria-live="polite"></p>',
' <button type="submit">Сохранить</button>',
'</form>',
].join('\n')),
paragraph('Контрол FileInput доступен в D7-ядре и умеет формировать HTML и JavaScript. Но значение <code>name</code> — часть договора с PHP, а не косметика. После первой подстановки шаблона я смотрю исходный HTML и реальный Request Payload: имя, число file-полей, код ответа и наличие <code>enctype=multipart/form-data</code>. Если форма отправляется Ajax-ом, проверяю, что код строит <code>FormData</code>, а не сериализует только текстовые inputs.'),
heading('jQuery показывает состояние, но не делает файл сохранённым'),
paragraph('Устаревший шаблон может переотрисовать блок формы через <code>.html()</code>. Прямой обработчик на старом input после этого исчезнет, а повторная инициализация способна добавить второй обработчик. Я привязываю событие к стабильному контейнеру и использую namespace: перед повторной инициализацией снимаю именно свой обработчик. В браузере это даёт один статус выбора и не меняет серверную модель.'),
codeBlock([
'(function ($) {',
' function bindPhotoForm(root) {',
' var $root = $(root);',
'',
' $root.off(\'change.photoWorkflow\', \'input[type=file][name=CATALOG_PREVIEW]\')',
' .on(\'change.photoWorkflow\', \'input[type=file][name=CATALOG_PREVIEW]\', function () {',
' var file = this.files && this.files[0];',
' var message = file',
' ? \'Выбран файл: \' + file.name + \'. Сохранение ещё не выполнено.\'',
' : \'Новый файл не выбран.\';',
'',
' $root.find(\'.js-photo-state\').text(message);',
' $root.find(\'input[name=KEEP_PICTURE]\').prop(\'checked\', !file);',
' $root.find(\'input[name=DELETE_PICTURE]\').prop(\'checked\', false);',
' });',
' }',
'',
' $(function () {',
' bindPhotoForm(document);',
' });',
'}(jQuery));',
].join('\n')),
paragraph('Метод <code>.on()</code> с селектором делегирует событие от потомка к уже существующему контейнеру. Это подходит для полей, которые появятся после перерисовки. Обработчик выше намеренно не кладёт имя или data URL в <code>PHOTO_ID</code>: такой ID существует только после серверного шага. Если нужны размеры картинки для preview, их можно показать рядом, но финальную проверку и привязку оставляю обработчику.'),
heading('На сервере выбираю ровно один путь'),
paragraph('Для формы редактирования полезно свести три пользовательских действия к трём веткам. Новый файл важнее флага «оставить»; явное удаление нельзя смешивать с новым файлом в одном запросе. При конфликте возвращаю ошибку формы, а не выбираю вариант по порядку полей. Это проще объяснить оператору и проще проверить через один POST.'),
codeBlock([
'function updatePreviewPicture($elementId, array $post, array $files)',
'{',
' $hasNewFile = isset($files[\'CATALOG_PREVIEW\'])',
' && ($files[\'CATALOG_PREVIEW\'][\'error\'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK;',
' $deleteRequested = ($post[\'DELETE_PICTURE\'] ?? \'\') === \'1\';',
'',
' if ($hasNewFile && $deleteRequested) {',
' throw new RuntimeException(\'Choose a new picture or deletion, not both\');',
' }',
'',
' if (!$hasNewFile && !$deleteRequested) {',
' return; // карточка сохраняет текущую картинку',
' }',
'',
' $fields = array();',
' if ($hasNewFile) {',
' $fields[\'PREVIEW_PICTURE\'] = $files[\'CATALOG_PREVIEW\'];',
' } else {',
' $fields[\'PREVIEW_PICTURE\'] = array(\'del\' => \'Y\');',
' }',
'',
' $element = new CIBlockElement();',
' if (!$element->Update((int) $elementId, $fields)) {',
' throw new RuntimeException($element->LAST_ERROR);',
' }',
'}',
].join('\n')),
paragraph('Это минимальный сценарий именно для поля изображения элемента. Если приложение сначала создаёт отдельную запись в <code>b_file</code>, я храню возвращённый ID в серверной сессии или черновике и при финальном сохранении снова проверяю права на элемент. Для повторного использования существующего файла документация Bitrix предлагает <code>CFile::MakeFileArray()</code>, принимающий и ID файла. Важно не смешивать эту ветку с «пользователь выбрал файл, но ещё не отправил форму».'),
heading('Проверяю не только счастливый путь'),
orderedList([
'Открываю существующую карточку, фиксирую текущий ID изображения и отправляю форму без нового файла: ID должен остаться прежним.',
'Выбираю небольшой тестовый файл, проверяю один POST с multipart-частью и убеждаюсь, что серверный ответ содержит успешный результат обновления.',
'Перезагружаю страницу отдельным запросом; preview и URL картинки должны соответствовать новому значению элемента.',
'Нажимаю «удалить» без нового файла и проверяю, что обработчик получает явный флаг, а не выводит удаление из пустого preview.',
'Имитирую Ajax-перерисовку контейнера и ещё раз меняю файл: статус должен смениться один раз, без двойного обработчика.',
'Отправляю новый файл вместе с удалением и ожидаю понятную ошибку формы, а не неявный выбор одной ветки.',
]),
heading('Где legacy-интеграция чаще всего обманывает'),
paragraph('Первый обман — вызывать <code>serialize()</code> для формы с файлом. Метод собирает текстовые поля и не переносит бинарное содержимое; для Ajax нужен <code>FormData</code> и правильные параметры запроса. Второй — считать, что серверный <code>200</code> подтвердил картинку: обработчик мог вернуть HTML ошибки или не обновить элемент. Третий — брать «последний созданный файл» из базы. В форме одновременно работают люди, поэтому связь должна идти из конкретного запроса и конкретного элемента.'),
paragraph('Ещё один риск связан с повторной инициализацией. Namespace в <code>change.photoWorkflow</code> даёт узкую очистку только нашего события; нельзя заменять его общим <code>off(\'change\')</code>, потому что так легко сломать чужой плагин на той же форме. В 2018 году это особенно важно для старых шаблонов, где порядок подключения JavaScript не документирован.'),
heading('Ограничения и следующий шаг'),
paragraph('Пример не определяет политику доступа, допустимые расширения и обработку больших изображений. Их надо связать с ролью пользователя, настройками PHP и правилами каталога на конкретной установке. Если FileInput уже загружает файл отдельным действием, не копируйте ветку с <code>$_FILES</code>: сначала посмотрите, какое значение возвращает контрол и кто владеет временным ID.'),
paragraph('Критерий готовности простой: после новой загрузки форма делает один понятный запрос, <code>Update()</code> проходит, а свежая страница показывает новое изображение. После сохранения без файла старая картинка остаётся. Этого достаточно, чтобы следующая правка формы не превратила DOM-preview в ложное подтверждение данных.'),
],
[bitrixFileInput, bitrixUpdate, bitrixMakeFileArray, jqueryOn, jqueryReady, phpFiles],
);
const jqueryWebpackArticle = createRevision(
{
slug: 'editorial-2019-01-mechanism-jquery-webpack',
title: 'jQuery в Webpack. Почему legacy-плагин ломается только в production',
categories: ['JavaScript', 'Webpack', 'jQuery'],
cover: '/assets/editorial/2019/webpack-jquery-order-2019.svg',
excerpt: 'В development маска работает, а после production-сборки читает undefined вместо window.jQuery. Разбираем порядок выполнения модуля, назначение ProvidePlugin и проверку графа ассетов.',
readingMinutes: 11,
},
[
paragraph('В development старая маска телефона работает, а после production-сборки браузер сообщает, что <code>window.jQuery</code> не определён. Цена ошибки — релиз с формой, которую нельзя заполнить, хотя локальный сервер и привычный исходный код выглядели исправными.'),
paragraph('В январе 2019 года я бы не лечил этот сбой таймаутом. У legacy-плагина есть жёсткое ожидание: в момент его выполнения должна существовать конкретная глобальная переменная. У Webpack другая модель: он собирает модули и может вынести общую зависимость в отдельный chunk. Нужно проверить, кто создаёт <code>window.jQuery</code>, когда это происходит и какой production-asset выполняет плагин.'),
heading('Почему development даёт ложную уверенность'),
paragraph('На локальном сервере плагин нередко случайно получает jQuery из старого тега <code>script</code>, из общего layout или из более простого bundle. Production меняет условия: включается минификация, меняется имя файлов, а Webpack 4 может выделить общие зависимости через <code>optimization.splitChunks</code>. Само выделение не является ошибкой. Ошибка появляется, когда HTML подключает ассеты не тем способом или bootstrap-код запускает плагин до назначения глобала.'),
paragraph('Поэтому фиксирую минимальный пример из трёх частей: entry, который создаёт глобал; legacy-плагин, который читает его при загрузке; и production HTML с фактическими тегами script. Пока в диагностике есть только исходники, а собранного <code>dist</code> нет, порядок исполнения остаётся предположением.'),
dataTable(
['Наблюдение', 'Вероятная причина', 'Короткая проверка', 'Действие'],
[
['В dev всё работает, в production — <code>undefined</code>', 'layout подключает локальный jQuery раньше bundle', 'открыть Network и сравнить production script tags', 'убрать случайный CDN-скрипт или сделать порядок явным'],
['В модуле доступен <code>$</code>, но плагин читает <code>window.jQuery</code>', 'ProvidePlugin обслуживает свободный идентификатор, а не скрытую договорённость плагина', 'поставить остановку перед загрузкой плагина и проверить <code>window.jQuery</code>', 'назначить глобал до runtime require плагина'],
['После splitChunks два разных jQuery', 'одна копия пришла из layout, другая — из bundle', 'проверить modules со словом <code>jquery</code> в stats.json', 'оставить один владелец и проверить граф production-сборки'],
['Форма ломается после кеша', 'HTML ссылается на старый набор hashed-ассетов', 'сравнить HTML и имена файлов текущего dist', 'публиковать HTML и ассеты как один артефакт'],
],
),
figure(
'/assets/editorial/2019/webpack-jquery-order-2019.svg',
'Временная шкала production-загрузки: runtime, vendors chunk с jQuery, bootstrap назначает window.jQuery, затем require запускает legacy-плагин и форму. Красным отмечен плохой вариант со статическим импортом плагина до тела bootstrap.',
'Для старого плагина важен не только факт установки jQuery, но момент, в который он читает window.jQuery.',
),
heading('Разделяю свободный идентификатор и глобальный объект'),
paragraph('Webpack описывает <code>ProvidePlugin</code> как способ автоматически подставить модуль, когда компилятор встречает свободный идентификатор в скомпилированном модуле. Конфигурация с <code>$</code> и <code>jQuery</code> помогает нашему исходному коду, где они используются без import. Но старый плагин может вообще не иметь свободного идентификатора: он может обратиться именно к <code>window.jQuery</code> во время выполнения своего файла. Это другой контракт, его нельзя проверить поиском по собственным модулям.'),
paragraph('Документация Webpack отдельно показывает сопоставление <code>window.jQuery</code> для библиотек с такой зависимостью. На старом проекте я всё равно предпочитаю маленький bootstrap-модуль с явным присваиванием: в нём видно владельца глобала и точку, после которой разрешено запускать plugin. Это не идеальная модульная архитектура, а ограниченный шов для уже существующего кода.'),
codeBlock([
'const webpack = require(\'webpack\');',
'',
'module.exports = {',
' mode: \'production\',',
' entry: {',
' site: \'./src/bootstrap-legacy.js\',',
' },',
' plugins: [',
' new webpack.ProvidePlugin({',
' $: \'jquery\',',
' jQuery: \'jquery\',',
' }),',
' ],',
' optimization: {',
' splitChunks: { chunks: \'all\' },',
' },',
'};',
].join('\n')),
paragraph('Эта настройка не даёт права подключить plugin в любом месте и ожидать правильный момент. Она лишь делает <code>$</code> и <code>jQuery</code> доступными там, где Webpack анализирует их как свободные имена. Глобальный объект, порядок загрузки документа и исполнение файлов остаются отдельной частью расследования.'),
heading('Не ставлю статический import перед созданием глобала'),
paragraph('В этом месте легко написать читающийся, но неверный код. Статические <code>import</code> описывают зависимости модуля до того, как выполнится его тело. Если legacy-плагин читает <code>window.jQuery</code> сразу при инициализации, он может сработать раньше строки с присваиванием. В примере ниже сначала настраивается глобал, затем плагин подключается через runtime <code>require()</code>.'),
codeBlock([
'import $ from \'jquery\';',
'',
'function exposeLegacyJQuery(jq) {',
' if (window.jQuery && window.jQuery !== jq) {',
' throw new Error(\'Two jQuery instances reached the page\');',
' }',
'',
' window.$ = jq;',
' window.jQuery = jq;',
'}',
'',
'exposeLegacyJQuery($);',
'require(\'inputmask/dist/jquery.inputmask\');',
'require(\'./legacy-form\');',
].join('\n')),
paragraph('Здесь <code>require()</code> выбран не потому, что он современнее import, а потому, что его вызов стоит после <code>exposeLegacyJQuery()</code> в runtime-порядке текущего модуля. Если плагин не читает глобал при загрузке, такой шов может быть не нужен. Но в конкретном production-сбое сначала проверяю это предположение на минимальном плагине и только затем меняю конфигурацию всего проекта.'),
heading('Собираю production-доказательство, а не только скрин консоли'),
paragraph('После исправления я запускаю production-сборку с JSON-статистикой. В stats-файле ищу модули jQuery и связь с entry, а в браузере смотрю Network и порядок инициаторов. Цель не в том, чтобы вручную угадать хеш vendor-файла: порядок должен обеспечивать runtime Webpack или генератор HTML, а не строка с именем вчерашнего chunk.'),
codeBlock([
'{',
' "scripts": {',
' "build:profile": "webpack --mode production --profile --json > dist/stats.json",',
' "serve:dist": "npx http-server dist -c-1"',
' }',
'}',
'',
'npm run build:profile',
'npm run serve:dist',
].join('\n')),
paragraph('На тестовой странице перед отправкой формы достаточно временно проверить два факта: <code>window.jQuery === $</code> в bootstrap-коде и наличие метода, который добавляет плагин. Затем эту диагностику убираю или оставляю за отдельным development-флагом. Production-страница должна открываться со свежим HTML, потому что новый bundle с прошлым списком тегов script проверяет не нашу конфигурацию, а несовместимый набор артефактов.'),
heading('Порядок проверки после сборки'),
orderedList([
'Собираю чистый production <code>dist</code> и сохраняю имя entry, runtime и общих chunks из фактического вывода.',
'Открываю страницу без локального CDN-скрипта jQuery и проверяю Network: все стартовые assets пришли без 404 и с одной версией артефакта.',
'Ставлю временную проверку в <code>bootstrap-legacy.js</code>: до runtime require <code>window.jQuery === $</code> должно быть <code>true</code>.',
'После require проверяю ровно один ожидаемый метод legacy-плагина, затем инициализирую форму через <code>$(function () { ... })</code>.',
'Открываю stats.json и ищу modules, содержащие <code>jquery</code>; повторная копия требует объяснения, а не только сравнения общего размера bundle.',
'Повторяю сценарий после очистки кеша и после прямого открытия страницы, чтобы не перепутать рабочий старый asset с новым релизом.',
]),
heading('Инициализирую форму после DOM, а не до него'),
paragraph('Даже правильный глобал не создаёт input в DOM. jQuery <code>.ready()</code> выполняет обработчик, когда документ готов к безопасному изменению, поэтому конечная инициализация формы должна жить после bootstrap-пути. Это отдельная проверка от загрузки plugins: если <code>window.jQuery</code> есть, а селектор не находит поля, причина лежит уже в разметке или в моменте появления формы.'),
codeBlock([
'require(\'./bootstrap-legacy\');',
'',
'jQuery(function ($) {',
' var $phone = $(\'#order-phone\');',
'',
' if ($phone.length !== 1 || typeof $phone.inputmask !== \'function\') {',
' throw new Error(\'Legacy mask is not ready for #order-phone\');',
' }',
'',
' $phone.inputmask(\'+7 (999) 999-99-99\');',
'});',
].join('\n')),
paragraph('Синтаксис с аргументом <code>$</code> внутри <code>jQuery(function ($) { ... })</code> снимает зависимость этой конкретной функции от внешнего alias. Но он не отменяет bootstrap: плагин всё ещё должен быть загружен и зарегистрирован до вызова <code>inputmask()</code>. Так граница проблемы остаётся видимой: глобал для старого плагина, локальный alias для кода формы, реальный DOM для финальной инициализации.'),
heading('Ограничения решения'),
paragraph('Глобальный jQuery — технический долг, а не совет для нового модуля. Новые компоненты лучше импортировать явно и не делать <code>window</code> общим API. Также не следует выключать <code>splitChunks</code> только ради одного плагина: сначала нужно показать, что именно нарушено — порядок тегов, два экземпляра библиотеки или статический import.'),
paragraph('Число chunks и их хеши зависят от версии Webpack, loaders и графа зависимостей. Поэтому статья не обещает фиксированное имя <code>vendors~site.js</code>. Её критерий другой: production HTML ссылается на выпуск одной сборки, глобал назначен до legacy-плагина, а проверка формы пройдена без случайного script из layout.'),
heading('Итог'),
paragraph('Production-сбой с jQuery в Webpack обычно не лечится ещё одним alias. Нужно назвать, какой код читает <code>window.jQuery</code>, создать его в bootstrap до runtime require и проверить фактический граф assets. После этого legacy-граница остаётся маленькой, а следующая миграция может заменить её модулем без скрытой глобальной зависимости.'),
],
[webpackShimming, webpackProvide, webpackV4Migration, webpackSplitChunks, jqueryReady, jqueryOn],
);
export const revisions = [imageOwnershipArticle, imageFormArticle, jqueryWebpackArticle]
.map(({ bodyLength, ...revision }) => revision);
const isDirectInvocation = process.argv[1]
&& path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isDirectInvocation) {
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-10-2019-01.mjs --print-revisions\n');
process.exitCode = 1;
}
}