#!/usr/bin/env node // Сборка архива для публикации в Яндекс Играх (треб. 1.22): dist/fishing-yandex.zip // — ТОЛЬКО index.html в корне архива (без docs/tests/screenshots/прочего). // Основной сборщик — `zip -j` (junk-путь, deflate), fallback — `python3 -m zipfile -c` // (доступность обеих команд проверяется заранее; если обеих нет — понятная ошибка). // После сборки — самопроверка: архив распаковывается в каталог tmp (встроенный // zip-ридер: центральная директория + zlib.inflateRawSync, без unzip) и проверяются // 5 пунктов: (a) ровно один файл index.html в корне; (b) размер < 100 МБ; // (c) имена без пробелов/кириллицы; (d) sha256 распакованного файла == sha256 // исходного index.html (byte-идентичность); (e) синтаксис JS, вынутый из // распакованного файла (тот же паттерн, что в tools/check.mjs), через node --check. // При любом FAIL — exit 1. Чистый Node, без зависимостей. // Запуск: node tools/pack.mjs import { execFileSync, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { existsSync, mkdtempSync, 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 { inflateRawSync } from 'node:zlib'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const htmlPath = join(root, 'index.html'); const distDir = join(root, 'dist'); const zipPath = join(distDir, 'fishing-yandex.zip'); const MAX_SIZE = 100 * 1024 * 1024; // 100 МБ — лимит требования 1.22 // ---------- Утилиты ---------- function fmtSize(bytes) { if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; } // Доступность команды: есть бинарник и она завершается с кодом 0. function cmdAvailable(cmd, args) { const r = spawnSync(cmd, args, { stdio: 'ignore' }); return !r.error && r.status === 0; } // sha256 файла, в hex. function sha256File(p) { return createHash('sha256').update(readFileSync(p)).digest('hex'); } // ---------- Минимальный zip-ридер (только чтение, для самопроверки) ---------- // Читает EOCD + центральную директорию; данные извлекает по локальным заголовкам. // Поддерживает method 0 (stored) и 8 (deflate) — ровно то, что дают `zip` и `python3 -m zipfile`. function readZipEntries(buf) { // EOCD-запись (0x06054b50) ищем с конца файла (коммент EOCD — до 64 КБ). let eocd = -1; const from = Math.max(0, buf.length - 22 - 65535); for (let i = buf.length - 22; i >= from; i--) { if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; } } if (eocd < 0) throw new Error('не ZIP-архив (EOCD не найден)'); const count = buf.readUInt16LE(eocd + 10); let off = buf.readUInt32LE(eocd + 16); const entries = []; for (let n = 0; n < count; n++) { if (buf.readUInt32LE(off) !== 0x02014b50) throw new Error('повреждённая центральная директория'); const method = buf.readUInt16LE(off + 10); const csize = buf.readUInt32LE(off + 20); const nameLen = buf.readUInt16LE(off + 28); const extraLen = buf.readUInt16LE(off + 30); const commentLen = buf.readUInt16LE(off + 32); const localOff = buf.readUInt32LE(off + 42); const name = buf.toString('utf8', off + 46, off + 46 + nameLen); // Начало данных считаем от локального заголовка (имя/extra в нём могут отличаться от центральных). if (buf.readUInt32LE(localOff) !== 0x04034b50) throw new Error('повреждённый локальный заголовок: ' + name); const lNameLen = buf.readUInt16LE(localOff + 26); const lExtraLen = buf.readUInt16LE(localOff + 28); const dataOff = localOff + 30 + lNameLen + lExtraLen; entries.push({ name, method, csize, dataOff }); off += 46 + nameLen + extraLen + commentLen; } return entries; } function extractEntry(buf, e) { const raw = buf.subarray(e.dataOff, e.dataOff + e.csize); if (e.method === 0) return Buffer.from(raw); // stored if (e.method === 8) return inflateRawSync(raw); // deflate throw new Error(`неподдерживаемый метод сжатия ${e.method}: ${e.name}`); } // ---------- Извлечение JS-блоков (тот же паттерн, что в tools/check.mjs) ---------- const JS_TYPES = new Set(['', 'text/javascript', 'application/javascript', 'module']); const SCRIPT_RE = /]*)>([\s\S]*?)<\/script>/gi; function jsBlocks(html) { const blocks = []; for (const m of html.matchAll(SCRIPT_RE)) { const attrs = m[1]; const code = m[2]; if (/\bsrc\s*=/i.test(attrs)) continue; const type = (attrs.match(/\btype\s*=\s*["']?([^"'\s>]*)/i)?.[1] ?? '').toLowerCase(); if (!JS_TYPES.has(type)) continue; if (!code.trim()) continue; blocks.push({ code, isModule: type === 'module' }); } return blocks; } // ---------- 1. Сборка ---------- if (!existsSync(htmlPath)) { console.error(`pack: не найден ${htmlPath}`); process.exit(1); } mkdirSync(distDir, { recursive: true }); if (existsSync(zipPath)) { try { rmSync(zipPath); // старый архив не оставляем — только свежая сборка } catch (e) { console.error(`pack: не удалось удалить старый архив ${zipPath}: ${e.message} (проверь права на файл/каталог) — сборка не запущена`); process.exit(1); } } // Проверяем сборщики заранее: основной — zip, fallback — Python (python3 → python → py, порядок важен для Windows). const hasZip = cmdAvailable('zip', ['-v']); const PY_ZIP_CANDIDATES = ['python3', 'python', 'py']; const pyZip = PY_ZIP_CANDIDATES.find(c => cmdAvailable(c, ['-m', 'zipfile', '--help'])) || null; let method; if (hasZip) method = 'zip'; else if (pyZip) method = pyZip; else { console.error('pack: нет ни `zip`, ни Python (`python3`/`python`/`py`) — собрать архив нечем (установи один из инструментов)'); process.exit(1); } console.log(`pack: собираю ${zipPath} (способ: ${method})`); try { if (method === 'zip') { // -j — junk-путь: в архив попадает только имя файла (index.html в корне, без каталогов). execFileSync('zip', ['-q', '-j', zipPath, 'index.html'], { cwd: root }); } else { execFileSync(pyZip, ['-m', 'zipfile', '-c', zipPath, 'index.html'], { cwd: root }); } } catch (e) { console.error(`pack: сборка завершилась ошибкой (${method}): ${e.message}`); process.exit(1); } // ---------- 2. Самопроверка ---------- let failed = false; const fail = (code, msg) => { failed = true; console.error(`FAIL (${code}) ${msg}`); }; const pass = (code, msg) => console.log(`PASS (${code}) ${msg}`); const zipBuf = readFileSync(zipPath); let entries; try { entries = readZipEntries(zipBuf); } catch (e) { console.error(`pack: не удалось прочитать архив: ${e.message}`); process.exit(1); } const tmp = mkdtempSync(join(tmpdir(), 'yandex-pack-')); try { // (a) в архиве ровно один файл index.html в корне if (entries.length === 1 && entries[0].name === 'index.html') { pass('a', 'в архиве ровно один файл в корне: index.html'); } else { fail('a', `ожидался ровно index.html в корне, а в архиве: ${entries.map(e => e.name).join(', ') || '(пусто)'}`); } // (b) размер архива < 100 МБ const zipSize = statSync(zipPath).size; if (zipSize < MAX_SIZE) pass('b', `размер архива ${fmtSize(zipSize)} < 100 МБ`); else fail('b', `размер архива ${fmtSize(zipSize)} >= 100 МБ`); // (c) имена без пробелов и кириллицы const badName = entries.find(e => /[ ]/.test(e.name) || /\p{Script=Cyrillic}/u.test(e.name)); if (!badName) pass('c', 'имена без пробелов/кириллицы: ' + entries.map(e => e.name).join(', ')); else fail('c', `в имени есть пробел или кириллица: ${badName.name}`); // (d)+(e) работаем с распакованным файлом в tmp const unpacked = join(tmp, 'index.html'); writeFileSync(unpacked, extractEntry(zipBuf, entries[0])); // (d) byte-идентичность: sha256 распакованного == sha256 исходного index.html const srcHash = sha256File(htmlPath); const zipHash = sha256File(unpacked); if (zipHash === srcHash) pass('d', `sha256 распакованного index.html совпадает с исходным (${zipHash.slice(0, 12)}…)`); else fail('d', `sha256 расходятся: исходный ${srcHash.slice(0, 12)}…, из архива ${zipHash.slice(0, 12)}…`); // (e) синтаксис JS из распакованного файла — node --check (паттерн tools/check.mjs) const blocks = jsBlocks(readFileSync(unpacked, 'utf8')); if (!blocks.length) { fail('e', 'в распакованном index.html не найдено