Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 | #!/usr/bin/env node
// SPDX-FileCopyrightText: 2024-2026 Hack23 AB
// SPDX-License-Identifier: Apache-2.0
//
// Minifies HTML, CSS, and JS files in place using pure-Node packages so that
// the deploy pipeline never needs to pull a Docker image (compatible with the
// `egress-policy: block` harden-runner configuration in deploy-s3.yml).
//
// Run order in deploy-s3.yml:
// 1. prebuild — generate all pages
// 2. optimize-css — PurgeCSS drops unused selectors from styles.css
// 3. minify-assets (THIS SCRIPT) — compress the now-smaller CSS + HTML + JS
// 4. rm -rf node_modules — clean up before S3 sync
// 5. aws s3 sync passes — upload minified payload
//
// Must run BEFORE `rm -rf node_modules` because html-minifier-terser,
// clean-css, and terser are devDependencies.
//
// Scopes:
// CSS — styles.css only (the one deployed stylesheet)
// HTML — root *.html + news/*.html (all pages shipped to S3)
// JS — js/**/*.js excluding *.min.js (vendor files already minified upstream;
// re-minifying risks stripping required license banners)
//
// HTML files are processed with a concurrency cap (CONCURRENCY) to avoid
// overwhelming the event loop on the 4400+ news/*.html archive while still
// finishing in reasonable time.
//
// Exits non-zero if any file fails so the deploy halts before uploading a
// partially-minified payload.
import { readFileSync, writeFileSync, readdirSync } from 'node:fs';
import { resolve, join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { minify as minifyHtml } from 'html-minifier-terser';
import CleanCSS from 'clean-css';
import { minify as minifyJs } from 'terser';
import { writeFileIfChanged } from './utils/file-utils.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, '..');
const CONCURRENCY = 32; // parallel HTML workers
// ─── helpers ────────────────────────────────────────────────────────────────
function fmt(before, after) {
const saved = before - after;
const pct = before > 0 ? ((saved / before) * 100).toFixed(1) : '0.0';
return `${before} → ${after} B (saved ${saved} B / ${pct}%)`;
}
function errorMessage(e) {
return e instanceof Error ? e.message : String(e);
}
/** Run tasks with at most `limit` in-flight at once. */
async function pool(tasks, limit) {
const results = [];
let next = 0;
async function worker() {
while (next < tasks.length) {
const task = tasks[next++];
results.push(await task());
}
}
const workers = Array.from({ length: Math.min(limit, tasks.length) }, worker);
await Promise.all(workers);
return results;
}
let totalBefore = 0;
let totalAfter = 0;
let errors = 0;
let unchangedCount = 0;
// ─── CSS ────────────────────────────────────────────────────────────────────
const cssPath = resolve(repoRoot, 'styles.css');
{
const src = readFileSync(cssPath, 'utf8');
const before = Buffer.byteLength(src, 'utf8');
const result = new CleanCSS({ level: 2 }).minify(src);
if (result.errors && result.errors.length) {
console.error('❌ clean-css errors in styles.css:', result.errors);
errors++;
} else {
const wrote = writeFileIfChanged(cssPath, result.styles);
if (!wrote) unchangedCount++;
const after = Buffer.byteLength(result.styles, 'utf8');
totalBefore += before;
totalAfter += after;
console.log(` styles.css ${fmt(before, after)}${wrote ? '' : ' (unchanged)'}`);
}
}
// ─── HTML ────────────────────────────────────────────────────────────────────
const htmlOpts = {
collapseWhitespace: true,
removeComments: true,
removeOptionalTags: false,
removeRedundantAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
minifyCSS: true,
minifyJS: true,
useShortDoctype: true,
};
// Collect HTML files: root *.html + news/*.html
const rootHtml = readdirSync(repoRoot)
.filter((f) => f.endsWith('.html'))
.map((f) => resolve(repoRoot, f));
const newsDir = resolve(repoRoot, 'news');
let newsHtml = [];
try {
newsHtml = readdirSync(newsDir)
.filter((f) => f.endsWith('.html'))
.map((f) => join(newsDir, f));
} catch {
// news/ directory may not exist in all environments
}
const allHtml = [...rootHtml, ...newsHtml];
let htmlBefore = 0;
let htmlAfter = 0;
let htmlErrors = 0;
const htmlTasks = allHtml.map((p) => async () => {
try {
const src = readFileSync(p, 'utf8');
const before = Buffer.byteLength(src, 'utf8');
const minified = await minifyHtml(src, htmlOpts);
const wrote = writeFileIfChanged(p, minified);
const after = Buffer.byteLength(minified, 'utf8');
return { before, after, ok: true, wrote };
} catch (e) {
console.error(`❌ HTML minify failed for ${p}: ${errorMessage(e)}`);
return { before: 0, after: 0, ok: false, wrote: false };
}
});
const htmlResults = await pool(htmlTasks, CONCURRENCY);
let htmlUnchanged = 0;
for (const r of htmlResults) {
if (r.ok) {
htmlBefore += r.before;
htmlAfter += r.after;
if (!r.wrote) htmlUnchanged++;
} else {
htmlErrors++;
}
}
totalBefore += htmlBefore;
totalAfter += htmlAfter;
errors += htmlErrors;
unchangedCount += htmlUnchanged;
console.log(
` HTML: ${allHtml.length - htmlErrors} files minified ${fmt(htmlBefore, htmlAfter)}` +
(htmlUnchanged > 0 ? ` (${htmlUnchanged} unchanged, mtime preserved)` : ''),
);
// ─── JS ─────────────────────────────────────────────────────────────────────
const jsDir = resolve(repoRoot, 'js');
let jsFiles = [];
function collectJs(dir) {
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
collectJs(full);
} else if (entry.name.endsWith('.js') && !entry.name.endsWith('.min.js')) {
// Skip *.min.js — vendor bundles are already minified upstream;
// re-minifying them is wasteful and risks stripping required license banners.
jsFiles.push(full);
}
}
} catch {
// js/ may not exist in test environments
}
}
collectJs(jsDir);
let jsBefore = 0;
let jsAfter = 0;
let jsErrors = 0;
const jsTasks = jsFiles.map((p) => async () => {
try {
const src = readFileSync(p, 'utf8');
const before = Buffer.byteLength(src, 'utf8');
const result = await minifyJs(src, {
compress: true,
mangle: true,
// Preserve /*! ... */ license banners (e.g. Chart.js, D3, MIT headers).
// terser 'some' keeps comments that start with ! or contain @license / @preserve.
format: { comments: 'some' },
});
if (result.code) {
const wrote = writeFileIfChanged(p, result.code);
const after = Buffer.byteLength(result.code, 'utf8');
return { before, after, ok: true, wrote };
}
// Terser succeeded but produced no output — log and skip (file stays as-is)
console.warn(`⚠️ terser returned no code for ${p} — skipping`);
return { before, after: before, ok: true, wrote: false };
} catch (e) {
console.error(`❌ JS minify failed for ${p}: ${errorMessage(e)}`);
return { before: 0, after: 0, ok: false, wrote: false };
}
});
const jsResults = await pool(jsTasks, CONCURRENCY);
let jsUnchanged = 0;
for (const r of jsResults) {
if (r.ok) {
jsBefore += r.before;
jsAfter += r.after;
if (!r.wrote) jsUnchanged++;
} else {
jsErrors++;
}
}
totalBefore += jsBefore;
totalAfter += jsAfter;
errors += jsErrors;
unchangedCount += jsUnchanged;
console.log(
` JS: ${jsFiles.length - jsErrors} files minified ${fmt(jsBefore, jsAfter)}` +
(jsUnchanged > 0 ? ` (${jsUnchanged} unchanged, mtime preserved)` : ''),
);
// ─── summary ────────────────────────────────────────────────────────────────
const savedTotal = totalBefore - totalAfter;
const pctTotal =
totalBefore > 0 ? ((savedTotal / totalBefore) * 100).toFixed(1) : '0.0';
console.log(
`✅ Minification complete: ${totalBefore} → ${totalAfter} B ` +
`(saved ${savedTotal} B / ${pctTotal}% across CSS + ${allHtml.length} HTML + ${jsFiles.length} JS` +
(unchangedCount > 0
? `; ${unchangedCount} file(s) byte-identical to existing — mtime preserved so aws s3 sync will skip them`
: '') +
')',
);
if (errors > 0) {
console.error(`❌ ${errors} file(s) failed to minify — aborting deploy.`);
process.exit(1);
}
|