const escapeHtml = (value) => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); const p = (text) => `
${text}
`; const h2 = (text) => `${escapeHtml(text)}`;
const ol = (items) => `| ${cell} | `).join('')}
|---|
| ${cell} | `).join('')}
default-src задаёт fallback, но не объясняет исключения; script-src управляет JavaScript, object-src none закрывает старый plugin-механизм, а base-uri self не даёт странице незаметно изменить базовый URL.'),
p('Nonce применяют к конкретному inline script, когда убрать inline-код сразу нельзя. Значение должно быть непредсказуемым и новым для ответа; статическая строка превращается в разрешение для любого, кто её узнал. Шаблон должен вставить nonce и в CSP, и в атрибут script, а логирование полного значения создаёт лишний риск.'),
table('Директива и её граница', ['Директива', 'Что ограничивает', 'Частая ошибка', 'Проверка'], [
['default-src', 'fallback для типов ресурсов', 'считать её полной политикой', 'проверить исключения по типам'],
['script-src', 'источники JavaScript и nonce', 'добавить unsafe-inline навсегда', 'найти все inline и third-party scripts'],
['object-src', 'plugin/object загрузку', 'оставить широкое значение', 'поставить none, если object не нужен'],
['base-uri', 'изменение базового URL', 'забыть директиву', 'ограничить self или отключить'],
['report-only', 'наблюдение нарушений', 'принять отчёт за блокировку', 'после анализа перейти к enforce'],
]),
h2('HSTS имеет момент включения'),
p('HSTS действует после того, как браузер получил заголовок через доверенный HTTPS-ответ. Он не защищает самый первый HTTP-переход, если домен ещё не известен браузеру; для этого существует отдельная политика preload с собственными требованиями и риском. includeSubDomains распространяет правило на поддомены, поэтому включать его можно только после проверки всех нужных имён.'),
p('Большой max-age нельзя трактовать как кнопку «попробовать». Если поддомен ещё не умеет HTTPS, браузер перестанет подключаться к нему по HTTP на весь период. Перед расширением политики проверьте redirect, сертификаты, mixed content и административные endpoint. Безопасность заголовка включает и возможность восстановить ошибочную конфигурацию.'),
figure('/assets/editorial/2027/author-manifesto-2027-editorial-decision-graph.svg', 'Граф security headers: карта ресурсов формирует CSP, HTTPS-ответ включает HSTS, а неизвестный script или неподготовленный поддомен останавливает расширение политики.', 'Схема показывает два независимых слоя. CSP управляет ресурсами страницы, HSTS — схемой соединения; один заголовок не заменяет другой.'),
h2('Runnable-пример: собрать минимальные headers'),
p('Функция принимает nonce длиной не менее 16 символов и max-age HSTS. Она возвращает набор заголовков или явную ошибку входа. Это учебный генератор: он не устанавливает response headers и не проверяет ваш шаблонизатор. Ожидаемый результат показывает, что CSP содержит nonce, а HSTS — числовой срок и includeSubDomains.'),
code(`import { buildSecurityHeaders } from './upgrade-2027-12.mjs';
const result = buildSecurityHeaders({
nonce: '7c2f1b8e9a4d6f0c',
hstsMaxAge: 31536000,
});
console.log(result.ok);
console.log(result.headers['Content-Security-Policy']);
console.log(result.headers['Strict-Transport-Security']);
// true
// default-src 'self'; script-src 'self' 'nonce-7c2f1b8e9a4d6f0c'; object-src 'none'; base-uri 'self'
// max-age=31536000; includeSubDomains`),
h2('Порядок внедрения'),
ol([
'Соберите список ресурсов страницы и найдите inline scripts, eval, object, iframe, внешние CDN и API. Не начинайте с копирования чужой политики.',
'Включите CSP в Report-Only и соберите нарушения по URL, директиве и типу ресурса. Отчёт не блокирует выполнение, поэтому не называйте его исправлением.',
'Уберите лишние источники, замените inline-код на файл или nonce и добавьте тест на отсутствие unsafe-inline и unsafe-eval без обоснования.',
'Переведите CSP в enforce на одной проверяемой странице и сравните ошибки загрузки с разрешённым списком.',
'Включите HSTS только после проверки HTTPS для основного домена и поддоменов. Начните с контролируемого max-age, затем расширяйте.',
'Проверьте rollback конфигурации: изменение header должно быть версионируемым, а не ручной правкой в одном proxy.',
]),
h2('Почему nonce не лечит XSS'),
p('Nonce разрешает конкретные скрипты, но не санитизирует пользовательский HTML и не исправляет небезопасный sink. Если приложение вставляет строку в innerHTML, разрешённый bootstrap может помочь атакующему выполнить уже загруженный код. CSP снижает последствия и ловит часть нарушений, но контекстное экранирование и безопасные API остаются обязательными.'),
p('Диагностические отчёты CSP тоже требуют осторожности: URL может содержать чувствительные параметры, а third-party ресурс может присылать много шума. В отчёте храните только нужные поля, ограничивайте доступ и отделяйте нарушение политики от подтверждённой уязвимости. Заголовок — контроль браузера, не verdict о безопасности приложения.'),
h2('Ограничения и следующий шаг'),
p('Генератор не проверяет браузерную поддержку, CDN, service worker, iframe-политику, сертификаты и preload. CSP Level 3 — рабочая редакция W3C, поэтому конкретную совместимость и статус директивы нужно сверять с целевыми браузерами. RFC 6797 не защищает первый небезопасный переход и не заменяет TLS.'),
p('Следующий шаг — взять одну страницу, собрать Report-Only нарушения, закрыть источники по одному и добавить автоматический тест на заголовки. После enforce отдельно проверьте HSTS на каждом поддомене и храните процедуру возврата конфигурации рядом с кодом, чтобы ошибка не требовала ручной импровизации.'),
], securityRefs);
const performanceRefs = [
{ key: 'lcp', use: 'Определяет Largest Contentful Paint и API наблюдения за крупнейшей отрисовкой, чтобы измерение имело точный объект.', boundary: 'Working Draft может изменяться; LCP не измеряет всю скорость страницы, отзывчивость или стабильность layout.' },
{ key: 'performance', use: 'Задаёт Performance Timeline и доступ к измеряемым entry, на которых строятся браузерные наблюдения.', boundary: 'Не задаёт ваши пороги, backend-агрегацию, sampling и причинность медленной страницы.' },
{ key: 'webVitals', use: 'Фиксирует рекомендованные пороги LCP, INP и CLS и правило p75 по сегментам для практического triage.', boundary: 'Это guidance, а не гарантия UX и не доказательство причины конкретной регрессии.' },
];
const mechanism = revision({
slug: 'editorial-2027-12-mechanism-author-manifesto',
title: 'Web performance budget: LCP, INP и CLS без одной магической метрики',
categories: ['Frontend', 'Производительность'],
cover: '/assets/editorial/2027/author-manifesto-2027-quality-rubric-matrix.svg',
excerpt: 'Как читать пользовательские web-метрики: разделить LCP, INP и CLS, выбрать пороги и не менять код по одному красивому числу.',
readingMinutes: 16,
}, [
p('Проблема начинается с отчёта «страница медленная». Цена такого диагноза — оптимизировать не тот участок: уменьшить JavaScript, пока главный баннер ждёт шрифт, или ускорить первый paint, оставив клик заблокированным длинной задачей. Одно среднее число скрывает разные виды задержки.'),
p('Причина — смешать LCP, INP и CLS в общий score без определения окна и percentile. LCP отвечает за крупнейший видимый элемент, INP — за отзывчивость взаимодействий, CLS — за неожиданные сдвиги. У каждой метрики свой источник, порог и способ исправления. Сначала нужно понять измерение, затем выбирать действие.'),
h2('Три метрики — три пользовательских вопроса'),
p('LCP показывает, когда крупнейший контентный элемент стал видимым в пределах загрузки. Большой LCP часто связан с TTFB, критическим CSS, размером изображения или шрифтом. INP оценивает задержку взаимодействий и указывает на работу main thread после ввода. CLS суммирует неожиданные сдвиги layout, например из-за изображения без размеров или поздней рекламы.'),
p('Метрика не говорит, где находится причина. Плохой LCP может быть следствием сервера, сети или браузера; плохой INP — длинной задачи, стороннего скрипта или тяжёлого обработчика; плохой CLS — отсутствующего места под контент. Поэтому budget должен включать и измерение, и диагностический разрез: URL, устройство, connection, release и элемент.'),
table('Как читать Core Web Vitals', ['Метрика', 'Вопрос пользователя', 'Хорошая граница', 'Первый разрез'], [
['LCP', 'крупнейший контент появился?', '≤ 2500 ms', 'TTFB, resource, element'],
['INP', 'интерфейс ответил после ввода?', '≤ 200 ms', 'long task, handler, device'],
['CLS', 'страница не сдвинулась?', '≤ 0.1', 'element, font, reserved space'],
['Percentile', 'у какой доли пользователей проблема?', 'p75 по сегменту', 'country, device, release'],
['Budget', 'какой порог блокирует выпуск?', 'явно в CI/monitoring', 'threshold + owner action'],
]),
h2('Budget не равен среднему'),
p('Среднее значение сглаживает хвост и может выглядеть здоровым при плохом опыте части пользователей. Для пользовательских web-метрик часто нужен p75 в определённом сегменте, но даже percentile не спасает от смешения мобильных и десктопных данных. Порог должен быть привязан к одинаковому URL, устройству, версии и периоду наблюдения.'),
p('Лабораторный Lighthouse и field data отвечают на разные вопросы. Лаборатория воспроизводима и удобна для CI, но не содержит реального разнообразия сети. Field data показывает пользователей, но зависит от sampling, трафика и состава сегмента. Решение об оптимизации подтверждайте обоими видами данных или честно называйте, какой слой измерен.'),
figure('/assets/editorial/2027/author-manifesto-2027-quality-rubric-matrix.svg', 'Матрица web-performance: LCP, INP и CLS имеют разные объекты и пороги; lab и field measurement нельзя складывать в один безымянный score.', 'Схема связывает метрику с вопросом и первым диагностическим разрезом. Улучшение одного показателя не доказывает исправление остальных.'),
h2('Runnable-пример: классифицировать три числа'),
p('Функция принимает миллисекунды LCP и INP, а также значение CLS. Она возвращает статус каждой метрики и общий худший статус. Это учебный классификатор, не реализация браузерного PerformanceObserver: реальные значения нужно собирать из API и агрегировать по сегментам. Входы ниже показывают пороги без округления.'),
code(`import { classifyWebVitals } from './upgrade-2027-12.mjs';
const release = classifyWebVitals({
lcpMs: 2180,
inpMs: 240,
cls: 0.08,
});
const invalid = classifyWebVitals({
lcpMs: 1200,
inpMs: -1,
cls: 0.02,
});
console.log(release.overall, release.lcp, release.inp, release.layout);
console.log(invalid.ok, invalid.reason);
// needs-improvement good needs-improvement good
// false vital-input-invalid`),
h2('Порядок поиска причины'),
ol([
'Определите URL, сегмент, percentile и окно измерения. Не сравнивайте p75 мобильного трафика со средним для всех устройств.',
'Для плохого LCP найдите element и разделите TTFB, загрузку ресурса и отрисовку. Для INP найдите interaction и long task; для CLS — shifted element.',
'Сформулируйте один budget на релиз и один diagnostic signal. Не блокируйте сборку по метрике, которую CI не может воспроизвести.',
'Проверьте lab fixture и field distribution отдельно. Разница между ними — информация о среде, а не повод выбрать удобный источник.',
'Измените один тяжёлый участок: critical resource, handler, image dimensions или layout reservation.',
'Повторите измерение тем же сегментом и окном. Снижение LCP не закрывает INP/CLS автоматически.',
]),
h2('Почему порог не является причинностью'),
p('Пересечение границы 2500 мс сообщает о классификации, но не объясняет, что исправить. Порог полезен для triage и разговора о риске, а не для выбора виновника. Если после preload LCP улучшился, это ещё не доказывает, что preload был единственной причиной: изменились сервер, кэш или состав трафика.'),
p('У performance есть побочный эффект оптимизации. Сжатие изображения уменьшает LCP, но может увеличить CPU-декодирование или ухудшить качество. Разделение JavaScript может помочь INP, но добавить запросы и повлиять на LCP. В budget следует держать соседние ограничения — error rate, size, long tasks — и проверять, что выигрыш одной метрики не создаёт новый долг.'),
h2('Ограничения и следующий шаг'),
p('Классификатор использует учебные пороги и не считает реальные percentile. W3C LCP и Performance Timeline описывают API и объект измерения, но не обещают, что конкретный dashboard правильно собрал данные. INP и CLS требуют своих источников и сегментации; один локальный запуск не является field evidence.'),
p('Следующий шаг — выбрать один URL и сделать таблицу p75 для LCP, INP и CLS по двум сегментам. Для каждой плохой строки добавьте element/interaction и один проверяемый сигнал причины. После оптимизации повторите ту же выборку и проверьте соседние метрики, а не только тот показатель, который был в заголовке задачи.'),
], performanceRefs);
const writingRefs = [
{ key: 'nistIncident', use: 'Даёт дисциплину подготовки, обнаружения, анализа, containment, восстановления и работы после инцидента для структуры эксплуатационной инструкции.', boundary: 'Не знает ваших команд, прав доступа, сервисных зависимостей и порогов остановки.' },
{ key: 'rfc2119', use: 'Фиксирует различие между обязательным, рекомендуемым и необязательным действием, чтобы инструкция не прятала приоритет в тоне.', boundary: 'Не является руководством по эксплуатации, не проверяет команду и не даёт разрешение менять production.' },
];
const field = revision({
slug: 'editorial-2027-12-field-author-manifesto',
title: 'Эксплуатационная инструкция: симптом, действие, откат и проверка',
categories: ['Техническая документация', 'Надёжность'],
cover: '/assets/editorial/2027/author-manifesto-2027-revision-handoff-loop.svg',
excerpt: 'Как написать короткую инструкцию для опасной операции, чтобы читатель видел вход, ограничения, обратимое действие и критерий завершения.',
readingMinutes: 15,
}, [
p('Проблема инструкции обнаруживается в первый же сбой: читатель знает, что сервис нездоров, но не понимает, какой командой начать и как не усугубить ситуацию. Цена расплывчатого текста — параллельные ручные действия, потеря исходных метрик и откат без проверки данных.'),
p('Причина — писать статью как последовательность уверенных советов. В эксплуатации важнее не количество команд, а граница каждой команды: какое условие должно быть истинным, какой результат ожидается и когда нужно остановиться. Reader-facing текст должен позволить сверить вход, выполнить один шаг и увидеть измеримый выход.'),
h2('Карточка операции — минимальная единица'),
p('Полезная инструкция начинается с симптома и scope: например, «5xx выше 5% на POST /payments в одном регионе». Затем идут precondition, действие, rollback и verification. Эти поля не формальность. Без scope оператор может отключить здоровый трафик; без precondition — выполнить команду на неправильной версии; без verification — принять завершение команды за восстановление.'),
p('Порядок должен отражать риск, а не удобство автора документа. Сначала сохранить наблюдаемый факт, потом ограничить влияние, затем изменить один рычаг. После действия нужен интервал наблюдения и критерий возврата. Если операция необратима, инструкция должна прямо сказать, что её нельзя запускать без отдельного разрешения и резервного пути.'),
table('Структура проверяемой инструкции', ['Поле', 'Что написать', 'Проверяемый вопрос', 'Нельзя заменять'], [
['Symptom', 'метрика, endpoint, время', 'что именно нарушено?', '«сервис плохой»'],
['Scope', 'регион, версия, процент', 'кого затрагивает?', '«все пользователи»'],
['Precondition', 'доступ, версия, backup', 'можно ли выполнять шаг?', '«должно работать»'],
['Action', 'одна команда/изменение', 'что изменится?', 'список несвязанных команд'],
['Rollback', 'обратное действие и условие', 'как вернуть состояние?', '«откатить при проблеме»'],
['Verification', 'метрика и окно', 'что считать восстановлением?', '«проверить вручную»'],
]),
h2('Глаголы задают риск'),
p('Рекомендации вроде «проверьте», «убедитесь» и «при необходимости» слишком широки, если рядом нет объекта. RFC 2119 полезен как дисциплина модальности: MUST можно применять к обязательной precondition, SHOULD — к шагу с допустимым исключением, а MAY — к необязательной диагностике. В русском тексте это можно перевести обычными словами, сохранив однозначность.'),
p('Каждый command block должен иметь входы и ожидаемый результат. Если команда меняет состояние, рядом укажите право, namespace и способ увидеть diff. Не вставляйте секрет в пример и не предполагайте, что читатель знает локальные alias. Хорошая краткость убирает лишние слова, но не убирает условия безопасности.'),
figure('/assets/editorial/2027/author-manifesto-2027-revision-handoff-loop.svg', 'Петля эксплуатационной инструкции: симптом и precondition ведут к одному действию, затем к проверке метрики и условному откату.', 'Диаграмма показывает reader-facing последовательность. Текст считается завершённым только после наблюдаемой проверки, а не после окончания команды.'),
h2('Runnable-пример: проверить карточку runbook'),
p('Функция принимает объект с шестью полями и проверяет, что каждое достаточно содержательно, rollback назван явно, а verification ссылается на наблюдаемый сигнал. Это учебная проверка структуры документа, не оценка литературного стиля и не разрешение выполнить команду. Вход ниже показывает минимальный принятый набор и отказ без измеримой проверки.'),
code(`import { validateRunbookCard } from './upgrade-2027-12.mjs';
const card = validateRunbookCard({
symptom: '5xx выше 5 процентов на POST /payments',
scope: 'region eu-west, release 42, 10 percent traffic',
precondition: 'есть доступ к flag и сохранён dashboard за 15 минут',
action: 'отключить flag payments-v2 для 10 процентов трафика',
rollback: 'вернуть flag payments-v2 после проверки результата',
verification: 'проверить error rate и p95 в течение 10 минут',
});
console.log(card.ok, card.order.join(' -> '));
// true symptom -> scope -> precondition -> action -> rollback -> verification`),
h2('Порядок редакторской проверки инструкции'),
ol([
'В первых двух абзацах назовите симптом и цену ошибки. Reader должен понять, для какой ситуации текст предназначен.',
'Сделайте scope измеримым: endpoint, регион, версия, доля трафика и временное окно.',
'Перед каждой опасной командой поставьте precondition и ожидаемый output. Если output не наблюдаем, шаг нельзя считать проверенным.',
'Разделите один шаг изменения и rollback. Для rollback укажите условие, а не только команду возврата.',
'Добавьте таблицу решений для соседних симптомов, чтобы reader не применил одинаковое действие к 500, timeout и 409.',
'Запустите учебный пример с валидной и неполной карточкой, затем перечитайте текст на мобильной ширине и уберите длинные строки.',
]),
h2('Технический текст не заменяет разрешение'),
p('Даже подробная инструкция не даёт права менять production. Доступ, approval и окно операции должны жить в локальном процессе, а статья должна честно указать, какие precondition ей неизвестны. Если шаг может удалить данные или нарушить доступность, reader должен увидеть остановку до команды, а не бодрый призыв продолжать.'),
p('Не стоит добавлять в runbook вымышленные метрики и имена сервисов только для гладкого чтения. Лучше оставить placeholder с точным описанием входа, чем заставить оператора повторить чужой пример. Учебный пример должен быть маркирован как учебный и не содержать секретов, настоящих hostnames или команд с необратимым эффектом.'),
h2('Ограничения и следующий шаг'),
p('Проверка карточки не знает прав, shell, облака, backup, lock и реальных порогов. NIST SP 800-61 задаёт общий цикл обращения с инцидентом, но ваша инструкция всё равно должна назвать локальные сигналы и способ остановки. RFC 2119 помогает выбрать модальность, но не тестирует исполнимость команды.'),
p('Следующий шаг — взять один существующий alert и переписать его в шесть полей, затем прогнать на staging с безопасным флагом и настоящей проверкой метрики. Если reader не может назвать ожидаемый output любого шага, вернитесь к precondition и добавьте наблюдаемый критерий.'),
], writingRefs);
export const revisions = deepFreeze([practice, mechanism, field]);
export function runTechnicalWritingFixture() {
const validCard = { symptom: '5xx выше 5 процентов на endpoint', scope: 'region eu-west release 42', precondition: 'есть доступ и сохранён dashboard', action: 'отключить flag на десяти процентах', rollback: 'вернуть flag после проверки', verification: 'проверить метрику error rate 10 минут' };
const cases = [
['security-headers-accept', buildSecurityHeaders({ nonce: '7c2f1b8e9a4d6f0c' }).ok, true],
['security-headers-reject', buildSecurityHeaders({ nonce: 'short' }).reason, 'security-header-input-invalid'],
['vitals-classify', classifyWebVitals({ lcpMs: 2180, inpMs: 240, cls: 0.08 }).overall, 'needs-improvement'],
['vitals-reject', classifyWebVitals({ lcpMs: -1, inpMs: 200, cls: 0.1 }).reason, 'vital-input-invalid'],
['runbook-accept', validateRunbookCard(validCard).ok, true],
['runbook-reject', validateRunbookCard({ ...validCard, verification: 'посмотреть' }).reason, 'verification-must-be-observable'],
];
const checks = cases.map(([id, actual, expected]) => ({ id, actual, expected, passed: actual === expected }));
return deepFreeze({ passed: checks.filter((item) => item.passed).length, total: checks.length, accepted: checks.every((item) => item.passed), checks });
}
export function verifyRevisionsAgainstFixture() {
const fixture = runTechnicalWritingFixture();
const articleChecks = revisions.map((item) => {
const text = bodyText(item.contentHtml);
return text.length >= 5000 && text.length <= 15000 && /