function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } const p = (text) => '

' + text + '

'; const h2 = (text) => '

' + text + '

'; const code = (text) => '
' + escapeHtml(text) + '
'; const ol = (items) => '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; const figure = (src, alt, caption) => '
' + escapeHtml(alt) + '
' + caption + '
'; const table = (caption, headers, rows) => '
' + headers.map((cell) => '').join('') + '' + rows.map((row) => '' + row.map((cell) => '').join('') + '').join('') + '
' + caption + '
' + cell + '
' + cell + '
'; function cloneFixed(value) { return JSON.parse(JSON.stringify(value)); } function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; } function plainText(html) { return html.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); } function bodyText(html) { return plainText(html.replace(/

Проверяемые источники<\/h2>[\s\S]*?(?=

|$)/, '')); } const REFERENCES = deepFreeze({ navigation: { title: 'W3C Navigation Timing Level 2', version: 'Working Draft, 25 February 2026', url: 'https://www.w3.org/TR/2026/WD-navigation-timing-2-20260225/' }, performance: { title: 'W3C Performance Timeline', version: 'Candidate Recommendation Draft, 21 May 2025', url: 'https://www.w3.org/TR/2025/CRD-performance-timeline-20250521/' }, resource: { title: 'W3C Resource Timing', version: 'Candidate Recommendation Draft, 20 April 2026', url: 'https://www.w3.org/TR/2026/CRD-resource-timing-20260420/' }, }); function sources(entries) { return ''; } const FIXED_CRITICAL_PATH_CASES = deepFreeze({ fast: { ttfb: 80, html: 140, blocking: 1 }, slowServer: { ttfb: 420, html: 150, blocking: 1 }, slowScript: { ttfb: 90, html: 150, blocking: 4 }, invalid: { ttfb: -1, html: 0, blocking: 0 }, }); export function createCriticalPathPlan(id = 'fast') { return FIXED_CRITICAL_PATH_CASES[id] ? deepFreeze(cloneFixed(FIXED_CRITICAL_PATH_CASES[id])) : undefined; } export function assessCriticalPathPlan(input) { if (!input || typeof input !== 'object' || input.ttfb < 0) return { status: 'stop', cause: 'invalid-measurement', action: 'проверить единицы и начало замера' }; if (input.ttfb > 300) return { status: 'check-server', cause: 'server-wait', action: 'разделить TTFB и время загрузки HTML' }; if (input.blocking > 2) return { status: 'check-render-blocking', cause: 'blocking-resources', action: 'найти CSS и script, блокирующие путь' }; return { status: 'baseline', cause: 'no-dominant-signal', action: 'проверить ресурсы и главный поток' }; } export function runCriticalPathFixture() { const checks = [ ['fast', 'baseline'], ['slowServer', 'check-server'], ['slowScript', 'check-render-blocking'], ['invalid', 'stop'], ].map(([id, expected]) => ({ id, expected, actual: assessCriticalPathPlan(createCriticalPathPlan(id)).status })); return deepFreeze({ passed: checks.filter((item) => item.expected === item.actual).length, total: checks.length, accepted: checks.every((item) => item.expected === item.actual), checks }); } function revision(meta, parts, referenceEntries) { const contentHtml = parts.join('') + h2('Проверяемые источники') + sources(referenceEntries); const proseLength = bodyText(contentHtml).length; if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength); return deepFreeze({ ...meta, contentHtml, proseLength }); } const refs = [ { key: 'navigation', use: 'Определяет навигационные временные метки и связь между этапами загрузки документа.', boundary: 'Рабочий черновик стандарта не сообщает значения метрик вашего браузера и не заменяет полевой сбор.' }, { key: 'performance', use: 'Определяет PerformanceEntry, getEntries и PerformanceObserver как основу чтения метрик.', boundary: 'Спецификация не задаёт пороги качества и не гарантирует одинаковый набор entry во всех движках.' }, { key: 'resource', use: 'Определяет записи ресурсов, размеры и render-blocking status для сетевого слоя страницы.', boundary: 'Не описывает серверную архитектуру, пользовательский опыт целиком или причинность конкретной задержки.' }, ]; const practice = revision({ slug: 'editorial-2027-06-practice-performance-capstone', title: 'Медленная первая загрузка: отделяем TTFB от блокирующих ресурсов', categories: ['Frontend', 'Производительность'], cover: '/assets/editorial/2027/performance-capstone-2027-critical-path-contract.svg', excerpt: 'Практический разбор критического пути: что измерить, прежде чем менять JavaScript и CSS.', readingMinutes: 14, }, [ p('Пользователь видит пустой экран, а команда получает только одну цифру «страница грузится 2,4 секунды». Цена ошибки — оптимизировать bundle, когда 1,6 секунды ушло до первого байта HTML, или ускорить сервер, когда браузер ждёт блокирующий CSS. Эти причины требуют разных владельцев и разных изменений.'), p('Разделим путь на три наблюдения: время до первого байта, получение HTML и блокировку от ресурсов. Учебный локальный сервер намеренно задаёт задержку, поэтому результат легко повторить. В браузере дополнительно прочитаем Navigation Timing и Resource Timing, но не будем выдавать одну метрику за пользовательский опыт.'), h2('Критический путь начинается до JavaScript'), p('Браузер не может построить документ, пока не получил HTML. После этого parser встречает CSS, обычные script и изображения, которые меняют порядок работы. TTFB (time to first byte) отвечает только за ожидание первого байта ответа. Он не включает весь HTML и тем более не описывает время до полезного пикселя.'), table('Разделяем время загрузки', ['Участок', 'Метрика', 'Что проверяем'], [ ['До ответа', 'responseStart − requestStart', 'сервер, сеть, proxy'], ['HTML', 'responseEnd − responseStart', 'размер и передача документа'], ['Ресурс', 'responseEnd − startTime', 'CSS, script, font, image'], ['Главный поток', 'long task или task duration', 'парсинг и выполнение JS'], ]), figure('/assets/editorial/2027/performance-capstone-2027-critical-path-contract.svg', 'Критический путь страницы от запроса до главного содержимого: серверная задержка, HTML, CSS и JavaScript.', 'Схема разделяет участки времени. Рядом с каждой задержкой нужен собственный замер, иначе исправление выбирается по догадке.'), h2('Локальный сервер с двумя режимами'), p('Вход примера — query-параметр mode. Для slow сервер ждёт 300 миллисекунд перед заголовками, для fast отвечает сразу. Клиент измеряет время до получения тела. Ожидаемый результат: медленный режим имеет больший TTFB, а не «плохой JavaScript». Запуск безопасен: сервер слушает только loopback.'), code([ "import { performance } from 'node:perf_hooks';", "import { createServer as createHttpServer } from 'node:http';", '', "const server = createHttpServer((request, response) => {", " const delay = new URL(request.url, 'http://local').searchParams.get('mode') === 'slow' ? 300 : 0;", ' setTimeout(() => {', " response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });", " response.end('
ready
');", ' }, delay);', '});', '', "server.listen({ host: '127.0.0.1', port: 0 }, async () => {", ' const address = server.address();', ' const port = address.port;', ' for (const mode of [\'fast\', \'slow\']) {', ' const started = performance.now();', " const response = await fetch('http://127.0.0.1:' + port + '/?mode=' + mode);", ' await response.text();', " console.log(mode, Math.round(performance.now() - started), response.status);", ' }', ' server.close();', '});', ].join('\n')), p('Ожидаемый вывод — две строки со статусом 200, причём slow примерно на 300 миллисекунд больше. Точное число зависит от машины, поэтому сравниваем режимы в одном запуске. Так появляется проверяемый симптом: различие находится на серверном ожидании. Для browser-показателей нужен браузерный прогон с тем же HTML и набором ресурсов.'), h2('Что читать в Navigation Timing'), p('Объект PerformanceNavigationTiming содержит временные точки навигации. Для диагностического сообщения достаточно вывести requestStart, responseStart, responseEnd и domContentLoadedEventEnd. Вычитание даёт длительность участка, но абсолютное число нужно сопоставлять с условиями: cache, сеть, устройство и режим браузера меняют картину.'), p('Не называйте domContentLoaded временем готовности экрана. Событие означает завершение разбора DOM и отложенных скриптов, но изображения и отрисовка могут продолжаться. FCP (first contentful paint) и LCP (largest contentful paint) относятся к визуальному результату, поэтому TTFB и DOMContentLoaded нельзя использовать как их замену. Для пользовательского симптома нужен отдельный критерий визуальной готовности.'), h2('Блокирующие ресурсы и компромисс'), p('Обычный CSS блокирует построение стилей, а синхронный script может остановить parser. Перенести всё в async недостаточно: порядок выполнения изменится, а код, который ожидает DOM или глобальную библиотеку, может сломаться. defer сохраняет порядок отложенных скриптов, но не убирает размер передачи.'), p('Поле renderBlockingStatus полезно читать только при поддержке конкретного браузера. Отсутствующее значение означает «нет данных», а не «ресурс точно не блокирует». Поэтому сверяйте запись с HTML-атрибутами и наблюдаемым изменением первого экрана.'), table('Выбор действия по наблюдению', ['Наблюдение', 'Действие', 'Цена'], [ ['высокий TTFB', 'профилировать серверный handler', 'нагрузка на backend и зависимости'], ['большой HTML', 'сжать или уменьшить ответ', 'сложнее шаблон и кэширование'], ['ранний блокирующий CSS', 'разделить critical и остальное', 'риск рассинхрона стилей'], ['долгий script', 'разбить работу или отложить', 'сложнее порядок и состояние'], ]), h2('Порядок проверки'), ol([ 'Записать пользовательский симптом и выбрать одну страницу с фиксированным URL.', 'Сравнить TTFB и время получения HTML на холодном и тёплом кэше.', 'Прочитать navigation entry и выписать длительности без округления до одной общей цифры.', 'Посмотреть resource entries и найти CSS, script или font, которые начинают работу раньше нужного.', 'Изменить один участок: сервер, размер HTML или порядок загрузки ресурса.', 'Повторить тот же прогон и сравнить не только среднее, но и медиану или p95.', ]), h2('Ограничения и следующий шаг'), p('Node-замер не знает о layout, paint, LCP и работе главного потока браузера. Navigation Timing — временная модель навигации, а не готовый ответ о причине. Сетевые записи могут быть скрыты политикой приватности или кросс-доменными ограничениями. Поэтому локальный пример проверяет только серверный участок.'), p('Следующим шагом подключите браузерный прогон к тестовой странице и сохраните четыре ряда: navigation, resources, long tasks и визуальную метрику. Меняйте одну причину за раз. Если TTFB стабилен, а экран задерживает CSS или JS, не переносите работу обратно на сервер.'), ], refs); const mechanism = revision({ slug: 'editorial-2027-06-mechanism-performance-capstone', title: 'Waterfall без иллюзий: как понять, какой ресурс блокирует страницу', categories: ['Frontend', 'Производительность'], cover: '/assets/editorial/2027/performance-capstone-2027-evidence-boundary-matrix.svg', excerpt: 'Разбираем waterfall по зависимостям, очереди и блокировке, а не по длине полоски на графике.', readingMinutes: 15, }, [ p('В waterfall длинная полоса у шрифта выглядит как причина медленной страницы, но шрифт мог начать загрузку после того, как главный контент уже отрисовался. Цена ошибки — менять порядок ресурсов без понимания зависимости, получить вспышку нестилизованного текста и увеличить количество сетевых запросов.'), p('Механика здесь простая: ресурс задерживает страницу только через конкретную зависимость — parser, CSSOM, layout, script или компонент, который ждёт ответ. Воспроизводимый пример покажет, как собрать записи ресурсов в браузере и отфильтровать их по времени старта и статусу блокировки.'), h2('Полоса времени не объясняет причинность'), p('Waterfall отвечает на вопрос «когда ресурс был активен». Он не отвечает на вопрос «почему экран ждал». Для причинности нужны DOM-порядок, атрибуты rel и async/defer, тип ресурса и событие, которое использовало результат. Две полосы одинаковой длины могут иметь разную цену: одна относится к hero-изображению, другая — к аналитике после загрузки.'), table('Читаем запись ресурса', ['Признак', 'Что он даёт', 'Следующая проверка'], [ ['name', 'какой URL запрошен', 'секреты и query удалены?'], ['initiatorType', 'кто начал запрос', 'parser, script, css или fetch'], ['startTime', 'когда началась активность', 'какое событие было раньше'], ['responseEnd', 'когда пришёл ответ', 'кто действительно ждал'], ['renderBlockingStatus', 'признак блокировки', 'поддерживает ли его браузер'], ]), figure('/assets/editorial/2027/performance-capstone-2027-evidence-boundary-matrix.svg', 'Матрица проверки ресурса: инициатор, время, статус блокировки, потребитель и действие.', 'Матрица не сортирует ресурсы по размеру. Она связывает строку waterfall с проверяемой зависимостью страницы.'), h2('Учебная страница с наблюдателем'), p('Это локальный HTML-фрагмент. Вставьте его в страницу, запущенную любым статическим сервером, откройте DevTools и посмотрите консоль. Входом являются browser PerformanceEntry. Результат — компактный список ресурсов, которые начали работу до события DOMContentLoaded, с указанием инициатора. Если браузер не поддерживает поле, выводится unknown, а не придуманное значение.'), code([ "const started = performance.getEntriesByType('resource').map((entry) => ({", ' name: new URL(entry.name).pathname,', ' initiator: entry.initiatorType,', ' start: Math.round(entry.startTime),', ' end: Math.round(entry.responseEnd),', " blocking: entry.renderBlockingStatus ?? 'unknown',", '}));', '', "const navigation = performance.getEntriesByType('navigation')[0];", 'const domEnd = navigation?.domContentLoadedEventEnd ?? Infinity;', 'console.table(started.filter((entry) => entry.start < domEnd));', ].join('\n')), p('Поля start и end — миллисекунды относительно начала навигации. Фильтр оставляет ресурсы, начавшие работу до DOMContentLoaded, но это ещё не список причин. Для каждой строки проверьте потребителя: script мог запросить JSON после DOM, а CSS мог повлиять на первый layout. Наблюдатель сокращает поиск, а не заменяет чтение кода.'), h2('Как CSS и script меняют порядок'), p('CSS, подключённый в head, нужен браузеру для построения стилей и поэтому часто попадает в раннюю часть цепочки. Обычный script без атрибута может остановить parser. defer загружает скрипт параллельно и исполняет его после разбора документа в порядке появления. async отдаёт порядок загрузке и подходит только для независимого кода.'), p('Любое изменение атрибута проверяется через зависимость. Если script обращается к DOM-узлу, defer обычно подходит. Если он ожидает библиотеку выше, async может дать race. Если стиль нужен только внутри раскрываемого блока, его можно разделить, но нужно проверить скачок layout и доступность. Критерий решения — не «полоса стала короче», а перестал ли нужный компонент ждать ресурс.'), h2('Размер и приоритет не одно и то же'), p('Большой ресурс может загружаться низким приоритетом после первого экрана, а маленький CSS блокировать парсер. Проверяйте размер вместе с инициатором, типом, приоритетом и моментом потребления. В Resource Timing есть данные о времени и размерах, но доступ к кросс-доменным значениям зависит от заголовков и политики браузера.'), table('Три изменения и их риск', ['Изменение', 'Положительный эффект', 'Что сломать легко'], [ ['defer для независимого script', 'parser не ждёт выполнение', 'код, рассчитывающий на ранний глобальный объект'], ['preload для критичного ресурса', 'запрос начинается раньше', 'лишний запрос и неправильный тип'], ['разделение CSS', 'меньше раннего CSS', 'FOUC, layout shift и порядок правил'], ]), h2('Порядок чтения waterfall'), ol([ 'Зафиксировать страницу, режим кэша, viewport и браузер, иначе строки несопоставимы.', 'Отфильтровать resource entries по initiatorType и времени до DOMContentLoaded.', 'Для каждой ранней строки найти тег, script или CSS-правило, которое её инициировало.', 'Проверить, кто читает ответ и действительно ли этот потребитель влияет на первый экран.', 'Изменить один атрибут или порядок загрузки и повторить запись.', 'Сравнить визуальный результат, long tasks и сетевой waterfall; не принимать сокращение одной полосы как успех.', ]), h2('Ограничения и следующий шаг'), p('PerformanceObserver и renderBlockingStatus зависят от поддержки браузера. Сетевые записи скрывают часть кросс-доменных данных, а локальная страница не отражает задержки мобильной сети. Визуальный результат зависит от шрифтов, viewport и устройства. Поэтому наблюдатель полезен как диагностический слой, но не как единственный критерий качества.'), p('Следующий шаг — сохранить в тестовом отчёте таблицу «ресурс → инициатор → потребитель → действие». В ней должны быть хотя бы один оставленный ресурс и один ресурс, который вы сознательно отложили. Это помогает объяснить компромисс и не возвращать preload или async при следующем изменении шаблона.'), ], refs); const field = revision({ slug: 'editorial-2027-06-field-performance-capstone', title: 'Измерение производительности, которому можно верить: фиксируем условия и p95', categories: ['Производительность', 'Инженерные практики'], cover: '/assets/editorial/2027/performance-capstone-2027-handoff-loop.svg', excerpt: 'Как оформить повторяемый замер страницы, чтобы одна удачная попытка не стала решением.', readingMinutes: 14, }, [ p('Фраза «после оптимизации стало быстрее» не даёт проверить ни исходное условие, ни размер выигрыша. Цена ошибки — принять удачный прогрев кэша за эффект изменения и откатить полезную работу, когда другой браузер покажет обратный результат. Измерение начинается с описания входа, а не с красивого числа.'), p('Соберём маленький локальный стенд: сервер отдаёт страницу с фиксированной задержкой, клиент делает серию запросов и считает медиану и p95. Такой тест не заменяет браузерный прогон, но показывает дисциплину измерения: одинаковый URL, количество повторов, единица времени, выбросы и границы вывода.'), h2('Число имеет смысл только вместе с условиями'), p('Для сравнения нужны версия сборки, браузер, viewport, режим кэша, сеть, размер данных и порядок действий. Если поменялось сразу пять условий, разность не принадлежит одному изменению. В отчёте сначала фиксируйте входные данные, затем распределение наблюдений и только потом пишите вывод.'), table('Минимальный протокол замера', ['Параметр', 'Пример значения', 'Зачем'], [ ['URL и commit', '/catalog, abc123', 'что именно измеряли'], ['Браузер и viewport', 'Chromium, 1280×800', 'какой клиент выполнял код'], ['Кэш', 'cold или warm', 'что уже было в storage'], ['Повторы', '20', 'насколько устойчиво число'], ['Метрика', 'TTFB, p95', 'какой участок сравниваем'], ]), figure('/assets/editorial/2027/performance-capstone-2027-handoff-loop.svg', 'Цикл замера: зафиксировать условия, выполнить повторения, посчитать распределение, изменить один фактор и сравнить.', 'Цикл возвращает к исходным условиям после каждого изменения. Без этого p95 не показывает, что именно дало разницу.'), h2('Локальный p95 на серии запросов'), p('Вход кода — число повторов и локальный endpoint. Сервер отвечает после одной и той же задержки, а клиент измеряет round trip. Ожидаемый результат — отсортированный список и p95, близкий к заданной задержке плюс накладные расходы Node. Это рабочий замер локального HTTP-пути, а не оценка интерфейса.'), code([ "import { performance } from 'node:perf_hooks';", "import { createServer as createMeasureServer } from 'node:http';", '', "const server = createMeasureServer((request, response) => {", ' setTimeout(() => response.end(\'ready\'), 40);', '});', '', 'function percentile(values, rank) {', ' const sorted = [...values].sort((a, b) => a - b);', ' return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * rank) - 1)];', '}', '', "server.listen({ host: '127.0.0.1', port: 0 }, async function measure() {", ' const address = server.address();', ' const port = address.port;', ' const samples = [];', ' for (let index = 0; index < 20; index += 1) {', ' const started = performance.now();', " await (await fetch('http://127.0.0.1:' + port)).text();", ' samples.push(performance.now() - started);', ' }', " console.log({ count: samples.length, median: percentile(samples, 0.5).toFixed(1), p95: percentile(samples, 0.95).toFixed(1) });", ' server.close();', '});', ].join('\n')), p('В результате count равен 20, медиана и p95 находятся около 40 миллисекунд. На загруженной машине числа выше, но их разброс остаётся видимым. Функция percentile здесь намеренно короткая: на малой выборке p95 — один элемент, поэтому отчёт должен сохранять размер серии и сами условия, а не только округлённый итог.'), p('Cold и warm cache нельзя смешивать в одной выборке. Если нужен общий отчёт, маркируйте режим каждой строки и сравнивайте одинаковые поднаборы. Иначе p95 может вырасти только потому, что несколько первых запросов скачали ресурсы.'), h2('Среднее скрывает хвост'), p('Среднее удобно для оценки общей стоимости, но пользователь попадает в хвост распределения. p95 означает, что 95 процентов измерений не превышают значение, а пять процентов превышают. Это не «медленность каждого запроса». При малом числе повторов один выброс сильно меняет p95, поэтому сравнивайте одинаковые объёмы выборки.'), p('Для страницы полезно хранить несколько метрик отдельно: TTFB, время до DOMContentLoaded, LCP, long tasks и размер ответа. Нельзя складывать их в один балл без явного решения. LCP зависит от содержимого и браузера, а TTFB — от серверной и сетевой части. Изменение, которое улучшило один участок, может ухудшить другой.'), h2('Как не перепутать корреляцию с причиной'), table('Сигнал и безопасный вывод', ['Сигнал', 'Можно сказать', 'Нужна дополнительная проверка'], [ ['TTFB выше', 'ответ начал приходить позже', 'backend, сеть и proxy'], ['p95 вырос', 'хвост стал тяжелее в этих условиях', 'нагрузка и размер выборки'], ['script появился раньше', 'порядок ресурсов изменился', 'влияние на render и DOM'], ['LCP изменился', 'визуальный критерий изменился', 'какой элемент стал LCP'], ]), p('Слово «корреляция» полезно только вместе с ограничением. Если TTFB и LCP выросли одновременно, это повод проверить сервер, но не доказательство, что сервер — единственная причина. Свяжите метрики по одному запуску, сохраните network log и посмотрите, какой элемент стал самым поздним крупным контентом.'), h2('Порядок замера'), ol([ 'Зафиксировать URL, код сборки, браузер, viewport, сеть и режим кэша.', 'Прогреть или очистить кэш одинаковым способом перед каждым набором.', 'Сделать одинаковое число повторов и сохранить сырые значения времени.', 'Посчитать median, p95 и диапазон, указав единицу измерения.', 'Изменить один фактор и повторить тот же набор.', 'Записать, какая метрика улучшилась, какая ухудшилась и какие вопросы остались открытыми.', ]), h2('Ограничения и следующий шаг'), p('Локальный HTTP-сервер не моделирует мобильную сеть, CPU браузера, кэш CDN и работу layout. Маленькая серия не годится для долгих выводов. W3C описывает интерфейсы измерения, но не устанавливает ваш порог приемки. Порог должен учитывать страницу, устройство и стоимость задержки.'), p('Следующий шаг — добавить к локальной серии один контролируемый browser-run и сохранить raw JSON вместе с условиями. Проверяйте распределение, а не только среднее. Если результат меняется после очистки кэша или при другом viewport, сначала уточните входные данные, затем выбирайте оптимизацию.'), ], refs); export const revisions = deepFreeze([practice, mechanism, field]); export function verifyRevisionsAgainstFixture() { const fixture = runCriticalPathFixture(); const articleChecks = revisions.map((item) => { const text = bodyText(item.contentHtml); return text.length >= 5000 && text.length <= 15000 && /(цен[аы]|стоимост|затрат|потер)/i.test(text.slice(0, 1100)) && //.test(item.contentHtml) && /
/.test(item.contentHtml) && /
/.test(item.contentHtml) && /
    /.test(item.contentHtml); }); return deepFreeze({ passed: fixture.passed + articleChecks.filter(Boolean).length, total: fixture.total + articleChecks.length, accepted: fixture.accepted && articleChecks.every(Boolean), fixture, articleChecks, characters: Object.fromEntries(revisions.map((item) => [item.slug, bodyText(item.contentHtml).length])) }); } if (process.argv.includes('--verify-fixture')) { const result = verifyRevisionsAgainstFixture(); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); if (!result.accepted) process.exitCode = 1; } if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');