Files
houseplan-card/demo/smoke_css_minifier_semantics.mjs
Codex 0a0a9f0f4c perf(build): the stylesheet minifier had never run
It looked for the tag written as `css` immediately followed by a
backtick. The plugin is a Rollup transform, so the module has already
been through TypeScript by the time it arrives, and the TS printer puts
a space there: `css `. The guard therefore returned null for every
stylesheet in the project, and minification never ran once — around
23 KB of explanatory comments went to every user in every release.

Matching the tag as a word with optional whitespace turns it on:

  chunk, raw       1 079 508 -> 1 021 115 B   (-58 393)
  initial view     300 111 -> 287 284 B gzip  (-12 816)
  room to the budget   955 -> 13 782 B

The ceiling moves down with the fact, as the tool asks when a graph
shrinks past the band.

The risk is not the two lines; it is that 23 KB of CSS is minified for
the first time. Two witnesses cover it: a browser smoke that puts the
original and the minified text into separate stylesheets and compares
the serialised rules — 1 049 of them, identical up to the whitespace
policy the minifier declares — and a test that takes real comment text
out of src/styles and requires it to be absent from dist, so a plugin
that silently stops working cannot pass again.

Issue: #526
User-Visible: yes
2026-09-11 10:12:13 +03:00

111 lines
5.3 KiB
JavaScript

// #526: включённая минификация не меняет смысл таблиц стилей.
//
// Минификатор молчал с самого начала (его отсев искал тег вплотную к бэктику,
// а плагин видит вывод TypeScript с пробелом), поэтому 23 КБ CSS проходят
// сжатие впервые. Риск задачи не в двух строках отсева, а в том, что где-то
// съеден значащий пробел — потомковый комбинатор, аргумент calc(), кавычка.
//
// Судим не глазами и не байтами, а браузером: исходный и минифицированный
// текст кладутся в отдельные CSSStyleSheet, и сериализованные правила обязаны
// совпасть посимвольно. Браузер сам приводит запись к канонической форме,
// поэтому совпадение cssText означает совпадение смысла.
import { readFileSync, readdirSync } from 'node:fs';
import { minifyCssText } from '../scripts/css-template-minifier.mjs';
import { launch, checkAll, finish } from './serve.mjs';
/** Тело статического css-шаблона модуля (интерполяций в них нет, см. ТЗ). */
const templateBody = (source) => {
const match = /(^|[^\w$.])css\s*`/.exec(source);
if (!match) return null;
const start = match.index + match[0].length;
const end = source.indexOf('`', start);
if (end < 0) return null;
const body = source.slice(start, end);
return body.includes('\\') || body.includes('${') ? null : body;
};
const files = [
...readdirSync(new URL('../src/styles/', import.meta.url))
.filter((name) => name.endsWith('.ts'))
.map((name) => [`src/styles/${name}`, new URL(`../src/styles/${name}`, import.meta.url)]),
['src/editor-secondary.styles.ts', new URL('../src/editor-secondary.styles.ts', import.meta.url)],
];
const pairs = [];
for (const [label, url] of files) {
const body = templateBody(readFileSync(url, 'utf8'));
if (!body) continue;
pairs.push({ label, source: body, minified: minifyCssText(body, label) });
}
const { page, browser } = await launch();
const out = await page.evaluate(async (input) => {
const result = {};
// Минификатор по контракту схлопывает пробельные пробеги и убирает пробел
// после запятой. В обычных значениях браузер делает то же самое сам, но
// значение СВОЙСТВА-ПЕРЕМЕННОЙ он хранит текстом как есть, поэтому такие
// правила отличались бы записью при полном совпадении смысла. Сравнение
// нормализует ровно объявленную политику пробелов минификатора — пробельные
// пробеги и пробел рядом с `,`, `:`, `(`, `)` — и ничего больше: пропавший
// потомковый
// комбинатор (`.a .b` → `.a.b`) или съеденный пробел в `calc(100% - 2px)`
// остаются расхождением и валят свидетеля.
const normalize = (text) => text
.replace(/\s+/g, ' ')
.replace(/([,:(])\s/g, '$1')
.replace(/\s\)/g, ')');
const rulesOf = (sheet) => {
const list = [];
const walk = (rules) => {
for (const rule of rules) {
list.push(normalize(rule.cssText));
if (rule.cssRules) walk(rule.cssRules);
}
};
walk(sheet.cssRules);
return list;
};
const mismatches = [];
let comparedRules = 0;
for (const pair of input) {
const before = new CSSStyleSheet();
const after = new CSSStyleSheet();
before.replaceSync(pair.source);
after.replaceSync(pair.minified);
const left = rulesOf(before);
const right = rulesOf(after);
comparedRules += left.length;
if (left.length !== right.length) {
mismatches.push(`${pair.label}: правил ${left.length} против ${right.length}`);
continue;
}
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
mismatches.push(`${pair.label}: правило ${index}\n было: ${left[index].slice(0, 160)}\n стало: ${right[index].slice(0, 160)}`);
break;
}
}
}
result.filesCompared = input.length;
result.enoughFiles = input.length >= 5;
result.rulesCompared = comparedRules;
result.enoughRules = comparedRules >= 500;
result.everyRuleIdentical = mismatches.length === 0;
result.mismatches = mismatches.slice(0, 3);
// Минификация обязана быть не просто безопасной, но и полезной.
const saved = input.reduce((sum, pair) => sum + pair.source.length - pair.minified.length, 0);
result.savedBytes = saved;
result.savesRealBytes = saved > 20000;
return result;
}, pairs);
console.log(JSON.stringify({ ...out, mismatches: out.mismatches }, null, 1));
checkAll(out, {
filesCompared: out.filesCompared,
rulesCompared: out.rulesCompared,
savedBytes: out.savedBytes,
mismatches: [],
});
await browser.close();
finish();