""" 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"", "", 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"(? 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()