mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-03-30 08:52:11 +02:00
290 lines
No EOL
9.9 KiB
Python
290 lines
No EOL
9.9 KiB
Python
"""
|
||
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() |