This commit is contained in:
cjkas 2026-03-10 20:23:48 +01:00
parent eb75868adb
commit a64b648f7a
56 changed files with 1003 additions and 0 deletions

290
minify.py Normal file
View file

@ -0,0 +1,290 @@
"""
PlatformIO pre-build script: minify & gzip web assets.
Copies files from data-src/ data/
HTML whitespace & comment removal, then gzip
CSS whitespace & comment removal, then gzip
JS whitespace & comment removal, then gzip
PNG copied as-is (optipng if available)
Other copied as-is
Usage in platformio.ini
-----------------------
extra_scripts = pre:minify.py
Directory layout
----------------
project/
data-src/ original, human-readable assets
index.html
css/
js/
img/
data/ auto-generated (gitignore this!)
minify.py
platformio.ini
The script runs automatically before LittleFS / SPIFFS image is built.
It also runs once at the start of every build so the data/ dir is always fresh.
Dependencies: none (pure Python 3.7).
Optional: optipng (for PNG optimisation)
"""
Import("env") # PlatformIO macro gives us the build environment
import gzip
import os
import re
import shutil
import subprocess
import sys
# ──────────────────────────────────────────────
# Config
# ──────────────────────────────────────────────
SRC_DIR_NAME = "data-src"
DST_DIR_NAME = "data"
# Extensions that will be minified + gzipped
MINIFY_AND_GZIP = {".html", ".htm", ".css", ".js", ".json", ".svg", ".xml"}
# Extensions that optipng can optimise
PNG_EXTENSIONS = {".png"}
# Everything else is copied verbatim
# ──────────────────────────────────────────────
def _project_dir():
return env.subst("$PROJECT_DIR")
def _src_dir():
return os.path.join(_project_dir(), SRC_DIR_NAME)
def _dst_dir():
return os.path.join(_project_dir(), DST_DIR_NAME)
# ──────────────────────────────────────────────
# Minifiers (pure Python, no npm needed)
# ──────────────────────────────────────────────
def minify_html(text: str) -> str:
"""Simple but effective HTML minifier."""
# Remove HTML comments (but keep IE conditional comments)
text = re.sub(r"<!--(?!\[if).*?-->", "", text, flags=re.DOTALL)
# Collapse whitespace between tags
text = re.sub(r">\s+<", "> <", text)
# Collapse runs of whitespace into a single space
text = re.sub(r"\s{2,}", " ", text)
# Remove whitespace around = in attributes
text = re.sub(r'\s*=\s*', '=', text)
# Strip leading/trailing whitespace per line, rejoin
lines = [line.strip() for line in text.splitlines() if line.strip()]
return " ".join(lines)
def minify_css(text: str) -> str:
"""Remove comments, collapse whitespace in CSS."""
# Remove comments
text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL)
# Remove whitespace around : ; { } ,
text = re.sub(r"\s*([:{};,])\s*", r"\1", text)
# Collapse remaining whitespace
text = re.sub(r"\s{2,}", " ", text)
# Strip leading/trailing
return text.strip()
def minify_js(text: str) -> str:
"""
Light JS minifier removes comments and collapses whitespace.
For heavy minification install terser and the script will use it
automatically (see _try_terser below).
"""
# Remove single-line comments (careful with URLs :// )
text = re.sub(r"(?<!:)//(?!/)[^\n]*", "", text)
# Remove multi-line comments
text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL)
# Collapse whitespace
text = re.sub(r"\s{2,}", " ", text)
lines = [line.strip() for line in text.splitlines() if line.strip()]
return "\n".join(lines)
def minify_json(text: str) -> str:
"""Compact JSON by removing unnecessary whitespace."""
import json
try:
data = json.loads(text)
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
except json.JSONDecodeError:
return text
def minify_svg(text: str) -> str:
"""Minimal SVG minifier comments + whitespace."""
text = re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
text = re.sub(r">\s+<", "><", text)
text = re.sub(r"\s{2,}", " ", text)
return text.strip()
MINIFIERS = {
".html": minify_html,
".htm": minify_html,
".css": minify_css,
".js": minify_js,
".json": minify_json,
".svg": minify_svg,
".xml": minify_svg, # same approach works for generic XML
}
# ──────────────────────────────────────────────
# Optional external tools (used when available)
# ──────────────────────────────────────────────
def _has_command(cmd: str) -> bool:
return shutil.which(cmd) is not None
def _try_terser(src_path: str, dst_path: str) -> bool:
"""Use terser for JS if installed (npm i -g terser)."""
if not _has_command("terser"):
return False
try:
subprocess.run(
["terser", src_path, "-o", dst_path, "--compress", "--mangle"],
check=True, capture_output=True,
)
return True
except subprocess.CalledProcessError:
return False
def _try_optipng(path: str) -> None:
"""Optimise PNG in-place if optipng is available."""
if _has_command("optipng"):
try:
subprocess.run(
["optipng", "-quiet", "-o2", path],
check=True, capture_output=True,
)
except subprocess.CalledProcessError:
pass
# ──────────────────────────────────────────────
# Core logic
# ──────────────────────────────────────────────
def process_file(src_path: str, dst_path: str) -> dict:
"""
Process a single file: minify, gzip or copy.
Returns a small stats dict.
"""
ext = os.path.splitext(src_path)[1].lower()
original_size = os.path.getsize(src_path)
stats = {"src": src_path, "original": original_size, "final": 0, "action": "copy"}
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
# ── Text assets: minify + gzip ──────────
if ext in MINIFY_AND_GZIP:
# Try terser for JS first
if ext == ".js" and _try_terser(src_path, dst_path + ".tmp"):
with open(dst_path + ".tmp", "rb") as f:
minified = f.read()
os.remove(dst_path + ".tmp")
stats["action"] = "terser+gzip"
else:
with open(src_path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
minifier = MINIFIERS.get(ext)
if minifier:
content = minifier(content)
stats["action"] = "minify+gzip"
else:
stats["action"] = "gzip"
minified = content.encode("utf-8")
# Write gzipped version
gz_path = dst_path + ".gz"
with gzip.open(gz_path, "wb", compresslevel=9) as gz:
gz.write(minified)
stats["final"] = os.path.getsize(gz_path)
return stats
# ── PNG: copy + optional optipng ────────
if ext in PNG_EXTENSIONS:
shutil.copy2(src_path, dst_path)
_try_optipng(dst_path)
stats["final"] = os.path.getsize(dst_path)
stats["action"] = "optipng" if _has_command("optipng") else "copy"
return stats
# ── Everything else: plain copy ─────────
shutil.copy2(src_path, dst_path)
stats["final"] = os.path.getsize(dst_path)
return stats
def minify_all():
src_dir = _src_dir()
dst_dir = _dst_dir()
if not os.path.isdir(src_dir):
print(f"[minify] WARNING: '{SRC_DIR_NAME}/' not found skipping.")
return
print(f"[minify] {SRC_DIR_NAME}/ → {DST_DIR_NAME}/")
# Clean destination
if os.path.exists(dst_dir):
shutil.rmtree(dst_dir)
os.makedirs(dst_dir, exist_ok=True)
total_original = 0
total_final = 0
file_count = 0
for root, dirs, files in os.walk(src_dir):
for fname in sorted(files):
# Skip hidden files and editor temp files
if fname.startswith(".") or fname.endswith("~"):
continue
src_path = os.path.join(root, fname)
rel_path = os.path.relpath(src_path, src_dir)
dst_path = os.path.join(dst_dir, rel_path)
stats = process_file(src_path, dst_path)
pct = (1 - stats["final"] / stats["original"]) * 100 if stats["original"] > 0 else 0
print(
f" {rel_path:<40s} "
f"{stats['original']:>8,d}{stats['final']:>8,d} B "
f"({pct:+.0f}%) [{stats['action']}]"
)
total_original += stats["original"]
total_final += stats["final"]
file_count += 1
print(f"[minify] {file_count} files processed")
print(
f"[minify] Total: {total_original:,d}{total_final:,d} bytes "
f"(saved {total_original - total_final:,d} bytes, "
f"{(1 - total_final / total_original) * 100:.0f}%)"
)
# ──────────────────────────────────────────────
# PlatformIO hooks
# ──────────────────────────────────────────────
# Run at the start of every build
minify_all()