256 lines
13 KiB
JavaScript
256 lines
13 KiB
JavaScript
#!/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) + ' МБ';
|
||
}
|