Files
fishing-game/tools/minify.mjs
T
andrey ad079b08f3 Инструмент: релизная сборка tools/minify.mjs (index.html −35%). (1) Методы: JS — terser compress passes:2 + mangle.toplevel:false (глобалы из буллета «Подводные камни» держатся именами — артефакт прогоняется обычной тестовой суитой); CSS — csso в каждом <style> КРОМЕ блока EMOJI-FONT (base64 woff2 держится побайтово, csso мог бы перенормировать кавычки); FISH-SVG — вырезаны HTML-комменты (~50 КБ описаний анатомии) и whitespace между тегами (в SVG текст-ноды не рендерятся — безопасно); прочие HTML-комменты — кроме маркеров EMOJI-FONT/FISH-SVG BEGIN/END (нельзя: whitespace внутри отрицательного lookaхeda, иначе <!-- EMOJI-FONT:BEGIN съедался бэктреком); whitespace DOM — только между блок-тегами (div/section/… + canvas/svg), inline-текст и пробелы между <b>…</b> и словом не трогаются. (2) Самопроверка 6 пунктов при любом FAIL exit 1: (a) размер, (b) node --check вынутого JS (паттерн tools/check.mjs), (c) маркеры по одному, (d) набор id идентичен (104), (e) видимый текст DOM идентичен, (f) блок EMOJI-FONT побайтово. (3) Артефакт dist/index.release.html: 541029 → 349886 байт (−35,3%), gzip 183366 → 128565; разбивка релиза: JS 112 КБ, SVG 120 КБ, шрифт 93 КБ, CSS 18 КБ, DOM ~5 КБ. (4) Валидация: ПОЛНАЯ тестовая суита на самом артефакте (подставлен под index.html) — 222/222 PASS, exit 0, headless Chromium. (5) Зависимости tools/: terser 5.51.2 + csso 5.0.5 (package.json + package-lock.json, npm i --prefix tools; tools/node_modules не коммитится — .gitignore +node_modules/, заодно прикрыл tests/node_modules). (6) index.html, balance, тесты, скриншоты НЕ тронуты. Доки: README (секция «Релизная (уменьшенная) сборка» в «Публикация в Яндекс Играх»), AGENTS.md (пункт 6 в «Проверка»: команда + методы + обязательный прогон tests/test.js на артефакте перед публикацией).
2026-09-24 19:25:04 +00:00

256 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Минификация Релизной сборки index.html → dist/index.release.html (для публикации).
//
// Что делает (по частям файла, маркеры блоков не трогаются):
// 1. <script> — terser: compress passes:2, mangle с toplevel:false.
// ВАЖНО: toplevel:false — имена глобалов (state, save, SPECIES, aqOpen, …) НЕ
// переименовываются: их пинит тестовая суита (tests/test.js через page.evaluate)
// и раздел AGENTS.md «Глобалы, которых нельзя переименовывать».
// 2. <style> — csso (каждый блок; EMOJI-FONT блок csso тоже проходит, но base64
// внутри src:url() остаётся побайтово — самопроверка (f) это гарантирует).
// 3. FISH-SVG — удаляются HTML-комменты (русс. описания анатомии рыб, ~50 КБ) и
// whitespace между тегами. В SVG текст-ноды не рендерятся, поэтому `>\s+<`→`><`
// безопасно; path-данные и атрибуты не трогаются.
// 4. Прочие HTML-комменты — удаляются, КРОМЕ маркеров EMOJI-FONT/FISH-SVG
// (BEGIN/END парами — нужны, чтобы блок оставался распознаваемым).
// 5. Whitespace DOM — схлопывается ТОЛЬКО между тегами блок-уровня (div/section/…).
// Inline-текст (в т.ч. пробелы между <b>…</b> и словом) не трогаем — самопроверка (e).
//
// Самопроверка (при любом FAIL — exit 1, артефакт не считается готовым):
// (a) размер выходного файла < входного;
// (b) синтаксис вынутого JS — node --check (тот же паттерн, что в tools/check.mjs);
// (c) маркеры EMOJI-FONT/FISH-SVG BEGIN/END сохранены (по 2 штуки);
// (d) набор id="…" в HTML идентичен оригиналу;
// (e) видимый текст DOM (вне script/style/SVG/комментов) идентичен оригиналу побайтово;
// (f) блок EMOJI-FONT (base64 woff2) идентичен оригиналу побайтово.
//
// Зависимости: terser, csso (npm i --prefix tools; package.json в tools/).
// Запуск: node tools/minify.mjs [путь-к-исходному-html] (по умолчанию index.html в корне репо)
//
// Дальше по желанию:
// - публикация веб-хостинга: положить dist/index.release.html как index.html;
// серверу включить gzip/brotli (тогда по сети пойдёт ещё меньше — см. вывод скрипта);
// - Яндекс Игры: заменить index.html на релизную версию и `node tools/pack.mjs`
// (pack сам проверит JS-синтаксис из архива), либо прогнать tests/test.js на копии.
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { createRequire } from 'node:module';
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gzipSync } from 'node:zlib';
import { minify } from 'terser';
const require = createRequire(import.meta.url);
const csso = require('csso');
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const htmlPath = process.argv[2] || join(root, 'index.html');
const distDir = join(root, 'dist');
const outPath = join(distDir, 'index.release.html');
if (!existsSync(htmlPath)) {
console.error(`minify: не найден ${htmlPath}`);
process.exit(1);
}
const src = readFileSync(htmlPath, 'utf8');
// ---------- 1. JS: terser ----------
const JS_RE = /<script>([\s\S]*?)<\/script>/;
const jsM = src.match(JS_RE);
if (!jsM) {
console.error('minify: не найден <script>-блок с JS');
process.exit(1);
}
let jsMin;
try {
jsMin = (await minify(jsM[1], {
compress: { passes: 2, module: false },
mangle: { toplevel: false }, // глобалы держим — см. шапку
format: { comments: false },
})).code;
} catch (e) {
console.error(`minify: terser не смог минифицировать JS: ${e.message}`);
process.exit(1);
}
let out = src.replace(jsM[1], () => jsMin); // function-замена: спецсимволы в JS-коде не влияют
// ---------- 2. CSS: csso в каждом <style>, КРОМЕ блока EMOJI-FONT ----------
// Блок шрифта — сгенерированный артефакт (tools/emoji-font.py), csso там всё равно
// ничего не сжимает (base64 не трогается), зато мог бы перенормировать кавычки —
// проходим мимо, держим побайтово (самопроверка (f) это ловит).
{
const fb = out.indexOf('EMOJI-FONT:BEGIN');
const fe = out.indexOf('EMOJI-FONT:END');
out = out.replace(/<style>([\s\S]*?)<\/style>/g, (all, css, off) => {
if (fb !== -1 && fe !== -1 && off > fb && off < fe) return all;
return '<style>' + csso.minify(css).css + '</style>';
});
}
// ---------- 3. FISH-SVG: без комментов, без whitespace между тегами ----------
const SVG_RE = /(<!-- FISH-SVG:BEGIN[^>]*-->)([\s\S]*?)(<!-- FISH-SVG:END -->)/;
const svgM = out.match(SVG_RE);
if (!svgM) {
console.error('minify: не найден блок FISH-SVG (маркеры BEGIN/END)');
process.exit(1);
}
const svgInner = svgM[2].replace(/<!--[\s\S]*?-->/g, '').replace(/>\s+</g, '><');
out = out.replace(svgM[0], () => svgM[1] + svgInner + svgM[3]);
// ---------- 4. Прочие HTML-комменты (кроме маркеров EMOJI-FONT/FISH-SVG) ----------
// Нюанс: whitespace идёт ВНУТРИ отрицательного lookахeda (`[^>]*`), а не `\s*` после
// `<!--` — иначе `\s*` откатывалось бы назад и маркер `<!-- EMOJI-FONT:BEGIN…` съедался.
out = out.replace(/<!--(?![^>]*(?:EMOJI-FONT|FISH-SVG):)[\s\S]*?-->[ \t]*\n?/g, '');
// ---------- 5. Whitespace DOM: только между блок-тегами ----------
const BLOCK = /^(div|body|section|header|footer|main|nav|ul|ol|li|table|canvas|svg)$/i;
{
let prevOpen = ''; // имя последнего открытого (и ещё не закрытого… упрощённо: последнего встреченного) тега
let outDom = '';
let i = 0;
while (i < out.length) {
const lt = out.indexOf('<', i);
if (lt < 0) { outDom += out.slice(i); break; }
outDom += out.slice(i, lt);
const gt = out.indexOf('>', lt);
if (gt < 0) { outDom += out.slice(lt); break; }
const tag = out.slice(lt, gt + 1);
if (tag.startsWith('</')) {
const nm = tag.slice(2, -1).trim().split(/\s/)[0].toLowerCase();
prevOpen = nm;
} else if (!tag.startsWith('<!--')) {
const nm = (tag.match(/^<([a-zA-Z][a-zA-Z0-9]*)/) || [])[1];
if (nm) prevOpen = nm.toLowerCase();
}
outDom += tag;
i = gt + 1;
// whitespace сразу после тега: съедаем, если закрыт блок-тег и дальше блок-тег/конец
let j = i;
while (j < out.length && /[\r\n \t]/.test(out[j])) j++;
let eat = false;
if (j > i && BLOCK.test(prevOpen)) {
if (j >= out.length) eat = true;
else if (out[j] === '<') {
if (out.startsWith('<!', j)) eat = false; // перед комментом не трогаем (маркер!)
else {
const head = out.slice(j, out.indexOf('>', j));
const nname = ((head.match(/^<\/?([a-zA-Z][a-zA-Z0-9]*)/) || [])[1] || '').toLowerCase();
eat = BLOCK.test(nname) || head.startsWith('</');
}
}
}
if (eat) i = j;
}
out = outDom;
}
// ---------- Запись ----------
mkdirSync(distDir, { recursive: true });
writeFileSync(outPath, out);
// ---------- Самопроверка ----------
let failed = false;
const fail = (code, msg) => { failed = true; console.error(`FAIL (${code}) ${msg}`); };
const pass = (code, msg) => console.log(`PASS (${code}) ${msg}`);
const srcBytes = Buffer.byteLength(src, 'utf8');
const outBytes = Buffer.byteLength(out, 'utf8');
// (a) стало меньше
if (outBytes < srcBytes) pass('a', `размер: ${fmt(srcBytes)} → ${fmt(outBytes)} (−${(100 * (1 - outBytes / srcBytes)).toFixed(1)}%)`);
else fail('a', `минификат (${fmt(outBytes)}) не меньше оригинала (${fmt(srcBytes)})`);
// (b) синтаксис JS из ВЫХОДНОГО файла (паттерн tools/check.mjs)
{
const blocks = [];
for (const m of out.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
if (/\bsrc\s*=/i.test(m[1])) continue;
if (m[2].trim()) blocks.push(m[2]);
}
if (!blocks.length) fail('b', 'в выходном файле не найдено <script>-блоков с JS');
else {
const tmp = join(tmpdir(), `minify-check-${process.pid}`);
mkdirSync(tmp, { recursive: true });
let synOk = true;
try {
blocks.forEach((code, i) => {
const f = join(tmp, `block-${i + 1}.js`);
writeFileSync(f, code);
const r = spawnSync(process.execPath, ['--check', f], { encoding: 'utf8' });
if (r.status !== 0) {
synOk = false;
fail('b', `синтаксис JS в блоке ${i + 1}: ${((r.stderr || r.stdout || '').trim()).split('\n')[0]}`);
}
});
if (synOk) pass('b', `синтаксис JS корректен (node --check, блоков: ${blocks.length})`);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}
}
// (c) маркеры сохранены
{
const cnt = (re) => (out.match(re) || []).length;
const okC =
cnt(/EMOJI-FONT:BEGIN/g) === 1 && cnt(/EMOJI-FONT:END/g) === 1 &&
cnt(/FISH-SVG:BEGIN/g) === 1 && cnt(/FISH-SVG:END/g) === 1;
if (okC) pass('c', 'маркеры EMOJI-FONT/FISH-SVG BEGIN/END сохранены (по одному)');
else fail('c', `маркеры потеряны: EMOJI-FONT BEGIN/END=${cnt(/EMOJI-FONT:BEGIN/g)}/${cnt(/EMOJI-FONT:END/g)}, FISH-SVG BEGIN/END=${cnt(/FISH-SVG:BEGIN/g)}/${cnt(/FISH-SVG:END/g)}`);
}
// (d) набор id идентичен
{
const ids = (s) => new Set([...s.matchAll(/id="([^"]+)"/g)].map((m) => m[1]));
const a = ids(src), b = ids(out);
const missing = [...a].filter((x) => !b.has(x));
const extra = [...b].filter((x) => !a.has(x));
if (missing.length === 0 && extra.length === 0) pass('d', `набор id идентичен (${a.size} шт)`);
else fail('d', `id расходятся: потеряно=[${missing}] лишнее=[${extra}]`);
}
// (e) видимый текст DOM идентичен (вне script/style/SVG/комментов)
{
const domText = (s) => {
let t = s;
t = t.replace(/<style>[\s\S]*?<\/style>/g, ' ');
t = t.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ');
t = t.replace(/<!--[\s\S]*?-->/g, ' ');
const i = t.search(/FISH-SVG:BEGIN/);
const j = t.indexOf('FISH-SVG:END');
if (i !== -1 && j !== -1) t = t.slice(0, i) + t.slice(j);
t = t.replace(/<[^>]+>/g, ' ');
return t.replace(/\s+/g, ' ').trim();
};
if (domText(src) === domText(out)) pass('e', 'видимый текст DOM идентичен оригиналу');
else fail('e', 'видимый текст DOM ИЗМЕНИЛСЯ (минификация съела текст)');
}
// (f) блок EMOJI-FONT побайтово
{
const font = (s) => {
const i = s.indexOf('EMOJI-FONT:BEGIN');
const j = s.indexOf('EMOJI-FONT:END');
return (i === -1 || j === -1) ? null : s.slice(i, j + 'EMOJI-FONT:END'.length);
};
const a = font(src), b = font(out);
if (a && b && a === b) pass('f', 'блок EMOJI-FONT (base64 woff2) идентичен оригиналу');
else fail('f', 'блок EMOJI-FONT изменён/потерян');
}
// ---------- Итог ----------
if (failed) {
console.error(`minify: FAIL — ${outPath} НЕ готов к публикации (см. пункты выше)`);
process.exit(1);
}
console.log(`minify: OK — ${outPath}`);
console.log(` размер: ${fmt(srcBytes)} → ${fmt(outBytes)} (−${(100 * (1 - outBytes / srcBytes)).toFixed(1)}%)`);
console.log(` gzip (оценка): ${fmt(gzipSync(Buffer.from(src, 'utf8')).length)} → ${fmt(gzipSync(Buffer.from(out, 'utf8')).length)}`);
console.log(' проверка работоспособности: подставь артефакт под index.html и прогони node tests/test.js (и/или tests/pack-smoke.js через pack.mjs)');
function fmt(bytes) {
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' КБ';
return (bytes / (1024 * 1024)).toFixed(2) + ' МБ';
}