import path from 'node:path'; import { fileURLToPath } from 'node:url'; const escapeHtml = (value) => String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const paragraph = (content) => '

' + content + '

'; const heading = (content) => '

' + content + '

'; const codeBlock = (source) => '
' + escapeHtml(source.trim()) + '
'; const figure = (src, alt, caption) => [ '
', '' + alt + '', '
' + caption + '
', '
', ].join(''); function dataTable(headers, rows) { const head = headers.map((header) => '' + header + '').join(''); const body = rows.map((row) => ( '' + row.map((cell) => '' + cell + '').join('') + '' )).join(''); return '
' + head + '' + body + '
'; } function orderedList(items) { return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; } function sourceList(items) { return heading('Проверяемые источники') + ''; } const sources = { entry: { label: 'Webpack 4 — Entry Points', url: 'https://v4.webpack.js.org/concepts/entry-points/', }, splitChunks: { label: 'Webpack 4 — Code Splitting', url: 'https://v4.webpack.js.org/guides/code-splitting/', }, optimization: { label: 'Webpack 4 — optimization.splitChunks и runtimeChunk', url: 'https://v4.webpack.js.org/configuration/optimization/', }, output: { label: 'Webpack 4 — Output', url: 'https://v4.webpack.js.org/concepts/output/', }, stats: { label: 'Webpack 4 — Stats Data', url: 'https://v4.webpack.js.org/api/stats/', }, cli: { label: 'Webpack 4 — CLI', url: 'https://v4.webpack.js.org/api/cli/', }, }; const practiceArticle = { slug: 'editorial-2018-06-practice-webpack-entry', title: 'Webpack 4. Как добавить две страницы и не возить общий код дважды', categories: ['JavaScript', 'Webpack'], cover: '/assets/editorial/2018/webpack-entry-shared-chunks-2018.svg', excerpt: 'Собираем две независимые страницы в Webpack 4: отделяем точки запуска от общего кода, подключаем нужные файлы в HTML и проверяем результат без догадок.', readingMinutes: 10, contentHtml: [ paragraph('В многостраничном сайте после добавления страницы оформления заказа появляются два entry-файла: catalog.js и checkout.js. Если обе страницы используют jQuery и один модуль с форматированием цены, production-build может дать два похожих файла. Это не ошибка Webpack: у него появились два старта выполнения, и каждый дошёл до общих импортов. Ошибка возникает, когда от сборщика ждут, что общий код исчезнет сам по себе.'), paragraph('Главный вопрос этой заметки: как в Webpack 4 собрать две HTML-страницы так, чтобы общий модуль и зависимости не попадали в каждый стартовый bundle? Ниже — небольшая конфигурация для многостраничного сайта. Она намеренно не использует современный dependOn: это не API Webpack 4.'), heading('Сначала отделяю две страницы от одной страницы с двумя файлами'), paragraph('Entry — это не список библиотек и не место, куда складывают всё «общее». Это файл, с которого браузер начинает конкретный сценарий. Если сервер отдаёт отдельные документы /catalog/ и /checkout/, у них могут быть два entry. Если же один документ подключает два entry только потому, что так проще в конфигурации, сначала стоит исправить это: пользователь будет загружать лишний сценарий ещё до оптимизации.'), figure( '/assets/editorial/2018/webpack-entry-shared-chunks-2018.svg', 'Две страницы Webpack 4: entry catalog и checkout используют runtime, vendors и common, затем каждая запускает только собственный код.', 'Entry остаётся точкой запуска страницы. Общие части создаёт оптимизация, а не третий фиктивный entry.', ), dataTable( ['Часть сборки', 'Зачем она нужна', 'Что подключает страница каталога'], [ ['catalog', 'Запускает обработчики и код каталога', 'Да'], ['checkout', 'Запускает только сценарий заказа', 'Нет'], ['vendors', 'Внешние пакеты из node_modules', 'Да, если попали в группу'], ['common', 'Наши модули, достигнутые из двух entry', 'Да, если группа их выделила'], ['runtime', 'Код Webpack, который связывает модули и chunks', 'Да'], ], ), heading('Минимальный пример с двумя сценариями'), paragraph('В примере оба entry импортируют один модуль из src/shared и jQuery. Содержимое функции не важно; важен путь импорта. Пока сборщик видит два стартовых графа, он имеет право положить достижимые модули в оба начальных файла.'), codeBlock(String.raw` // src/catalog.js import $ from 'jquery'; import { formatPrice } from './shared/money'; $('[data-price]').each(function () { this.textContent = formatPrice(this.dataset.price); }); // src/checkout.js import $ from 'jquery'; import { formatPrice } from './shared/money'; $('[data-total]').text(formatPrice(window.checkoutTotal)); // src/shared/money.js export function formatPrice(value) { return Number(value).toFixed(2) + ' ₽'; } `), heading('Конфигурация для Webpack 4'), paragraph('В Webpack 4 отдельный entry для vendor.js уже не является хорошей отправной точкой. Официальная документация советует оставлять entry только у начала выполнения, а разделение внешних и общих модулей поручить optimization.splitChunks. В конфигурации ниже minSize: 0 нужен для учебного примера: без него крошечный money.js может остаться в entry. В реальном проекте этот ноль обычно слишком агрессивен — он может создать лишний запрос ради пары строк.'), codeBlock(String.raw` // webpack.config.js const path = require('path'); module.exports = { mode: 'production', entry: { catalog: './src/catalog.js', checkout: './src/checkout.js', }, output: { path: path.resolve(__dirname, 'dist'), filename: '[name].[contenthash].js', }, optimization: { runtimeChunk: 'single', splitChunks: { chunks: 'all', cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, name: 'vendors', chunks: 'all', priority: -10, }, common: { name: 'common', minChunks: 2, minSize: 0, chunks: 'all', priority: -20, reuseExistingChunk: true, }, }, }, }, }; `), paragraph('У runtimeChunk: "single" здесь своя работа: Webpack 4 выносит runtime в один общий файл вместо того, чтобы встраивать его в каждый entry. Это не замена splitChunks. Первый вариант управляет runtime, второй — группами модулей. На этой границе легко запутаться, поэтому я проверяю оба результата в каталоге dist.'), paragraph('У правил выделения тоже есть граница. vendors смотрит только на путь внутри node_modules; пакет, который нужен одному checkout, не обязан переезжать в общий файл. common смотрит на повторное достижение модуля из двух chunks. Поэтому я не называю папку shared гарантией оптимизации: имя папки помогает человеку, а решение принимает правило сборки по графу и его условиям.'), paragraph('Перед тем как менять пороги, полезно сохранить список ассетов первого build. После изменения я сравниваю не общую сумму каталога, а роли файлов: появился ли vendors, попал ли money.js в common, остался ли код каталога в catalog. Так видно, какое именно правило сработало, и не приходится угадывать по одному числу в терминале.'), heading('Проверка не заканчивается на появлении файлов'), paragraph('После сборки должны появиться файлы с именами, зависящими от хеша: runtime.[contenthash].js, vendors.[contenthash].js, при нашем маленьком примере common.[contenthash].js, а также catalog.[contenthash].js и checkout.[contenthash].js. Хеши нельзя вшивать в шаблон HTML вручную. Шаблонизатор, плагин или серверный код должен получить актуальный список ассетов из сборки.'), codeBlock(String.raw` `), paragraph('Имена в примере условные. Важен набор: страница каталога не должна подключать checkout, а страница заказа — catalog. Если общий chunk выделен, он нужен обеим. После этого открываю обе страницы с пустым кешем, смотрю Network и проверяю, что на каждой нет ошибки undefined is not a function от неправильного порядка скриптов.'), heading('Короткий порядок работы'), orderedList([ 'Назвать HTML-документы, которые действительно существуют, и создать по одному entry на документ.', 'Найти импорт, который повторяется в двух entry: сначала достаточно одного модуля из src/shared.', 'Включить splitChunks для начальных chunks и временно поставить minSize: 0, чтобы увидеть механизм на маленьком примере.', 'Вывести runtime в один файл, собрать production-вариант и передать актуальный список ассетов в HTML.', 'Открыть каждую страницу отдельно: проверить набор script-тегов, консоль и факт, что код другой страницы не загружается.', ]), heading('Где этот рецепт не подходит'), paragraph('Не всякий общий импорт стоит выносить. Маленький модуль может добавить ещё один запрос и не дать выигрыша; крупная библиотека, которая нужна только модальному окну, не должна попадать в стартовый общий chunk только потому, что так легче настроить. Для кода, который не нужен при первом открытии страницы, в Webpack 4 есть отдельный путь — динамический import(). Ещё одно ограничение: если один HTML-документ намеренно запускает несколько entry, нужно особенно внимательно проверить число runtime-экземпляров и порядок загрузки.'), heading('Что считаю готовым'), paragraph('Я не считаю задачу закрытой по размеру одного файла. Готовый результат отвечает на три простых вопроса: какой entry запускает страницу, какие общие chunks она реально получает и не подключён ли соседний entry. Если эти ответы видны в конфигурации, в HTML и в Network, оптимизацию потом можно менять без лотереи.'), sourceList([sources.entry, sources.splitChunks, sources.optimization, sources.output]), ].join('\n'), }; const mechanismArticle = { slug: 'editorial-2018-06-mechanism-webpack-entry', title: 'Webpack 4. Почему общий import оказывается в двух entry bundle', categories: ['JavaScript', 'Webpack'], cover: '/assets/editorial/2018/webpack-entry-graph-2018.svg', excerpt: 'Разбираем один вопрос: что именно Webpack строит от entry, почему массив файлов — всё ещё один старт, и где появляется дублирование до настройки splitChunks.', readingMinutes: 10, contentHtml: [ paragraph('После добавления admin.js в конфигурацию и site.js, и admin.js могут содержать date-format.js. Руки тянутся перенести модуль в отдельную папку или добавить третий entry с названием vendor. Это не объясняет причину. Файл уже общий на диске; проблема возникает позже, когда Webpack строит стартовые графы.'), paragraph('Главный вопрос здесь один: почему один и тот же import попадает в два entry bundle до настройки общего chunk? Разобрав этот механизм, можно отличить две настоящие страницы от одного entry с подготовительными файлами и не превратить библиотеку в фальшивую точку запуска.'), heading('Entry не равен bundle, но задаёт его начало'), paragraph('Webpack начинает с entry и рекурсивно проходит import и require. Результатом становится граф зависимостей. При одном entry у графа один старт. При объекте из site и admin — два старта. Если оба пути доходят до одного модуля, сам модуль остаётся одним исходным файлом, но без дополнительного правила может оказаться в обоих начальных chunks.'), figure( '/assets/editorial/2018/webpack-entry-graph-2018.svg', 'Граф Webpack 4: entry site и admin проходят к своим модулям и оба достигают shared/date-format и jquery; до splitChunks общие зависимости могут присутствовать в обоих стартовых chunks.', 'Две стрелки к одному исходнику не означают две копии файла в репозитории. Они объясняют, почему сборщик должен отдельно решить судьбу общего участка графа.', ), heading('Минимальный граф, который показывает проблему'), codeBlock(String.raw` // src/site.js import { formatDate } from './shared/date-format'; import { mountSearch } from './site/search'; mountSearch(formatDate); // src/admin.js import { formatDate } from './shared/date-format'; import { mountReport } from './admin/report'; mountReport(formatDate); // src/shared/date-format.js export function formatDate(date) { return date.getFullYear() + '-' + String(date.getMonth() + 1).padStart(2, '0'); } `), paragraph('В этом примере site/search и admin/report принадлежат разным страницам. shared/date-format достижим из обеих. Это полезная граница: переносить search в общий chunk ради симметрии не нужно; он не нужен админке. А date-format можно рассматривать как кандидата на общий chunk, если цена дополнительного файла оправдана.'), dataTable( ['Запись в entry', 'Сколько стартов выполнения', 'Когда использовать'], [ ['"./src/site.js"', 'Один', 'Одна страница или библиотека с одним началом'], ['["./src/polyfills.js", "./src/site.js"]', 'Один', 'Нужно выполнить подготовительный файл перед главным кодом той же страницы'], ['{ site: "./src/site.js", admin: "./src/admin.js" }', 'Два', 'Сервер выдаёт два независимых HTML-документа'], ['{ vendor: ["jquery"], site: "./src/site.js" }', 'Два, один из них фиктивный', 'Для Webpack 4 это плохая модель; общий код выделяет splitChunks'], ], ), heading('Почему массив не создаёт вторую страницу'), paragraph('Массив в entry имеет другой смысл: Webpack 4 собирает указанные файлы как один multi-main entry и обходит их зависимости в одном chunk. Это подходит для полифиллов или кода подготовки, который всегда должен выполниться перед приложением. Массив не создаёт отдельную страницу и не заменяет объектную запись для многостраничного сайта.'), codeBlock(String.raw` // Один entry: polyfills и сайт попадают в один стартовый граф. entry: ['./src/polyfills.js', './src/site.js'] // Два entry: сервер обязан отдать нужный набор файлов каждой странице. entry: { site: './src/site.js', admin: './src/admin.js', } `), heading('Где Webpack 4 разделяет общий участок'), paragraph('До Webpack 4 встречалась привычка писать отдельный entry для библиотек и подключать CommonsChunkPlugin. В документации Webpack 4 этот путь уже помечен как нежелательный: entry должен соответствовать старту выполнения, а внешний и общий код выделяет optimization.splitChunks. Правило не обещает, что любой общий модуль обязательно станет отдельным файлом: на результат влияют условия группы, размер и тип chunk.'), codeBlock(String.raw` optimization: { splitChunks: { chunks: 'all', cacheGroups: { common: { name: 'common', minChunks: 2, minSize: 0, chunks: 'all', }, }, }, } `), paragraph('Здесь я снова ставлю minSize: 0 только для наглядности. Условия говорят: найти модуль, использованный хотя бы в двух chunks, и вынести его в файл common. В production-конфигурации сначала стоит снять реальные размеры, а потом вернуть порог, который не дробит приложение на множество мелких файлов.'), heading('Runtime — соседняя, но другая деталь'), paragraph('После выделения общего модуля иногда кажется, что дублирование осталось: в каждом entry виден служебный код Webpack. У runtime отдельная роль — он знает, как загружать модули и chunks. В Webpack 4 по умолчанию runtime встроен в entry; runtimeChunk: "single" создаёт один общий runtime-файл. Это решение полезно для нескольких страниц, но не нужно путать его с переносом общего прикладного модуля.'), paragraph('Если одна HTML-страница всё же включает несколько entry, у неё есть дополнительный риск: документация Webpack 4 предупреждает, что импортированные модули инициализируются для каждого runtime отдельно. Поэтому два script-тега не являются нейтральным приёмом. Сначала стоит проверить, нельзя ли оставить один старт и сделать вторую часть модулем внутри него.'), heading('Порядок проверки графа'), orderedList([ 'Выписать HTML-документы и ответить, нужен ли каждому отдельный старт JavaScript.', 'Проверить, не является ли список файлов в массиве одним entry, где второй файл нужен только для подготовки.', 'Найти модуль, который достигается от двух независимых стартов, и не выносить в общий код модули, нужные одной странице.', 'Настроить splitChunks на маленьком примере, затем вернуть проектный порог размера.', 'Отдельно решить, нужен ли единый runtime, и проверить фактические script-теги каждой страницы.', ]), heading('Граница объяснения'), paragraph('Эта модель отвечает только на вопрос о начальных chunks. Она не говорит, что нужно вынести каждый импорт, и не заменяет анализ загрузки по действию пользователя. Модуль для редкого окна или отчёта может быть лучше загрузить через динамический import(). Ещё важно помнить о версии: конфигурация и названия опций в тексте относятся к Webpack 4; пример из свежей документации с новыми полями entry нельзя без проверки вставлять в старый проект.'), heading('Итог'), paragraph('Один import попадает в два entry bundle не потому, что файл лежит «не в той папке». Он достижим из двух стартов. Сначала нужно назвать эти старты, затем решить судьбу общего участка графа через splitChunks и только потом смотреть на размер файлов. Такой порядок оставляет в конфигурации причину, а не случайную заплатку.'), sourceList([sources.entry, sources.splitChunks, sources.optimization]), ].join('\n'), }; const fieldArticle = { slug: 'editorial-2018-06-field-webpack-entry', title: 'Webpack 4. Bundle вырос после нового entry: как найти причину по stats.json', categories: ['JavaScript', 'Webpack'], cover: '/assets/editorial/2018/webpack-entry-diagnosis-2018.svg', excerpt: 'Пошаговая диагностика Webpack 4: отделяем новые ассеты от реального дублирования, читаем stats.json и проверяем, что браузер действительно скачивает.', readingMinutes: 10, contentHtml: [ paragraph('Симптом: после добавления admin-entry вырос site.[contenthash].js, а Network обычной страницы показывает запрос к admin.[contenthash].js. Пользователь получает код панели, которой не откроет; если править только сумму файлов в dist, легко оставить этот лишний запрос или сломать подключение нужного entry.'), paragraph('Главный вопрос статьи: как по данным Webpack 4 доказать, почему bundle вырос после добавления entry, прежде чем менять конфигурацию? Для ответа нужны три вещи: список emitted-ассетов, связь модуля с chunks и фактические script-теги в HTML. Одной цифры из файловой системы недостаточно.'), heading('Сначала фиксирую условия сравнения'), paragraph('Сравнивать development-результат с production-результатом бессмысленно: режим, минификация, source map и плагины меняют картину сильнее, чем новый entry. Я делаю два production-build на одном коммите: до изменения и после него. Для каждого сохраняю JSON статистики отдельно, например stats-before.json и stats-after.json.'), figure( '/assets/editorial/2018/webpack-entry-diagnosis-2018.svg', 'Диагностика роста Webpack bundle: фиксируем одинаковый build, смотрим assets, связываем модули с chunks, проверяем HTML и только затем меняем splitChunks.', 'Статистика сборщика показывает состав компиляции. Network в браузере отвечает на отдельный вопрос: что реально скачала конкретная страница.', ), heading('Создаю stats.json из той же команды сборки'), paragraph('Webpack умеет отдать статистику компиляции в JSON. В ней есть ассеты, chunks, модули и их связи. Команду лучше запускать локальным webpack-cli из проекта: тогда версия сборщика совпадает с той, для которой написан webpack.config.js.'), codeBlock(String.raw` # В package.json уже есть webpack и webpack-cli. ./node_modules/.bin/webpack --mode production --profile --json > stats-after.json # Для второго снимка возвращаем только конфигурацию entry # и повторяем ту же команду: ./node_modules/.bin/webpack --mode production --profile --json > stats-before.json `), paragraph('Параметр --profile добавляет сведения о времени по модулям. Для вопроса о размере он не обязателен, но снимок пригодится, если рост размера сопровождается долгой сборкой. Главное — не смешивать JSON со случайными console.log из конфигурации: файл должен остаться валидным JSON.'), heading('Читаю сначала ассеты, а не весь граф'), paragraph('Первый разрез простой: сортирую emitted-ассеты по size. Это показывает, какие выходные файлы появились и какие из них стали больше. Но размер в stats — размер ассета в сборке, а не обязательно число байтов, переданных по сети после gzip или кеширования. Поэтому это место для гипотезы, а не для вывода о скорости страницы.'), codeBlock(String.raw` // tools/print-webpack-stats.js const fs = require('fs'); const stats = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); const assets = (stats.assets || []) .map((asset) => ({ name: asset.name, size: asset.size, chunks: asset.chunks || [], })) .sort((left, right) => right.size - left.size); for (const asset of assets) { console.log(asset.size + '\\t' + asset.name + '\\tchunks=' + asset.chunks.join(',')); } const repeated = (stats.modules || []) .filter((module) => Array.isArray(module.chunks) && module.chunks.length > 1) .map((module) => ({ name: module.name, chunks: module.chunks, size: module.size, })) .sort((left, right) => right.size - left.size); console.log('\\nModules present in more than one chunk:'); for (const module of repeated.slice(0, 30)) { console.log(module.size + '\\t' + module.name + '\\tchunks=' + module.chunks.join(',')); } `), paragraph('Запуск node tools/print-webpack-stats.js stats-after.json не должен автоматически объявлять все строки из второго списка проблемой. Общий модуль может быть правильно связан с несколькими chunks в описании компиляции, а часть chunks может быть асинхронной. Список нужен, чтобы назвать конкретный модуль, после чего его надо сопоставить с entrypoint и HTML.'), dataTable( ['Наблюдение', 'Что это может означать', 'Следующее действие'], [ ['Появился новый admin.[contenthash].js, старый site.[contenthash].js почти не изменился', 'В dist лежит ещё одна страница, но старая не стала тяжелее', 'Проверить, что старый HTML не подключает admin'], ['Один пакет из node_modules виден у двух initial chunks', 'Внешняя зависимость достигнута из двух entry и не вынесена', 'Проверить splitChunks и условия cache group'], ['Оба entry подключены в одном HTML', 'Шаблон страницы получает чужой сценарий', 'Исправить генерацию script-тегов до настройки оптимизации'], ['Рост только в development', 'Сравнение сделано в разных режимах или с source map', 'Повторить замер одинаковой production-командой'], ['Файл большой в stats, но не запрашивается на странице', 'Ассет существует, но не входит в нужный entrypoint', 'Смотреть Network для конкретного URL, а не сумму каталога'], ], ), heading('Проверяю entrypoint и сетевой след'), paragraph('В stats есть сведения о chunks и entrypoints. Если новый admin должен жить только на /admin/, я открываю обычную страницу и смотрю список скриптов в HTML и вкладку Network. На ней должны быть только runtime, общие chunks, нужные именно этой странице, и её entry. Если там уже есть admin, проблема находится в шаблоне или плагине, а не в размере модуля.'), paragraph('Затем повторяю проверку для /admin/. Только когда один и тот же большой модуль действительно участвует в двух начальных путях, есть смысл добавлять cache group. В Webpack 4 оптимизация общих chunks по умолчанию ориентирована на динамические imports; для начальных chunks нужно явно выбрать подходящую конфигурацию. Это объясняет, почему «поставил второй entry» и «получил отдельный общий файл» не равны друг другу.'), heading('Небольшая правка после доказательства'), paragraph('Когда stats показал повторяющийся пакет, а обе страницы действительно его загружают, я добавляю минимальную группу, а не копирую чужой длинный конфиг. Сначала отделяю пакеты из node_modules. Общий код приложения стоит выносить отдельным правилом только после того, как видно повтор из двух entry и он достаточно велик для отдельного запроса.'), codeBlock(String.raw` optimization: { splitChunks: { chunks: 'all', cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, name: 'vendors', chunks: 'all', }, }, }, runtimeChunk: 'single', } `), paragraph('После изменения я создаю третий stats-fixed.json и повторяю те же три проверки. Ожидаемый результат формулирую не как «стало мало килобайт», а как наблюдаемый контракт: обычная страница не загружает admin-entry; общий пакет появился в предназначенном для него chunk; обе страницы получают все необходимые файлы без ошибки выполнения.'), heading('Последовательность расследования'), orderedList([ 'Сохранить два stats-снимка из одинаковой production-команды и назвать версии webpack и webpack-cli.', 'Сравнить ассеты: какой файл вырос, какой появился, связан ли он с новым entry.', 'Найти крупные модули, отмеченные в нескольких chunks, и не путать этот сигнал с доказательством сетевой загрузки.', 'Открыть каждый HTML-маршрут с пустым кешем и проверить реальные script-теги и Network.', 'Только после подтверждения дублирования настроить одну cache group, пересобрать и повторить тот же снимок.', ]), heading('Ограничения метода'), paragraph('Stats JSON отражает конкретную версию Webpack 4 и состав компиляции. Названия полей и формат данных могут меняться после обновления сборщика, поэтому диагностический скрипт не стоит превращать в вечный CI-контракт без фиксации версии. Метод также не измеряет время первой отрисовки и не учитывает серверное сжатие; для этого нужен отдельный сетевой замер. Но он надёжно отделяет «в каталоге стало больше файлов» от конкретного вопроса «какой модуль попал в какой chunk и почему».'), heading('Итог'), paragraph('Новый entry сам по себе увеличивает число ассетов — это ожидаемо. Дублирование начинается не от количества файлов, а от повторно достижимого модуля и от того, какие chunks подключает HTML. stats.json даёт материал для первой части проверки, браузер — для второй. После такой пары доказательств настройка splitChunks становится короткой и объяснимой.'), sourceList([sources.stats, sources.cli, sources.entry, sources.optimization]), ].join('\n'), }; export const revisions = [practiceArticle, mechanismArticle, fieldArticle]; const isDirectExecution = Boolean(process.argv[1]) && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (isDirectExecution) { 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-06.mjs --print-revisions\n'); } }