90 lines
3.2 KiB
Python
90 lines
3.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Flatten and minify the site's CSS imports without changing their order.
|
|
|
|
The page HTML loads css/main.css. Keeping this file as a generated bundle removes
|
|
the browser's serial @import chain while the individual files under css/ remain
|
|
the editable source of truth.
|
|
"""
|
|
import argparse
|
|
import posixpath
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
OUTPUT = ROOT / "css" / "main.css"
|
|
SOURCE = ROOT / "css" / "source.css"
|
|
IMPORT_RE = re.compile(r"@import\s+['\"]([^'\"]+\.css)(?:\?v=[0-9a-f]{8})?['\"]\s*;", re.I)
|
|
URL_RE = re.compile(r"url\(\s*(['\"]?)(\.\.?/[^)'\"\s]+)\1\s*\)", re.I)
|
|
LIGHTNINGCSS = "lightningcss-cli@1.33.0"
|
|
|
|
|
|
def rebase_urls(css: str, source: str) -> str:
|
|
"""Make relative url() references resolve from css/main.css after bundling."""
|
|
source_dir = posixpath.dirname(source)
|
|
|
|
def replace(match: re.Match) -> str:
|
|
quote, value = match.groups()
|
|
target = posixpath.normpath(posixpath.join(source_dir, value))
|
|
from_bundle = posixpath.relpath(target, "css")
|
|
return f"url({quote}{from_bundle}{quote})"
|
|
|
|
return URL_RE.sub(replace, css)
|
|
|
|
|
|
def build(output: Path) -> None:
|
|
source_text = SOURCE.read_text(encoding="utf-8")
|
|
imports = IMPORT_RE.findall(source_text)
|
|
if not imports:
|
|
raise RuntimeError("css/source.css has no CSS imports to bundle")
|
|
chunks = []
|
|
for import_path in imports:
|
|
rel = posixpath.normpath(posixpath.join("css", import_path))
|
|
path = ROOT / rel
|
|
if not path.is_file():
|
|
raise FileNotFoundError(f"Missing CSS source: {rel}")
|
|
chunks.append(rebase_urls(path.read_text(encoding="utf-8"), rel))
|
|
input_path = ROOT / "css" / ".main.build.css"
|
|
input_path.write_text("\n".join(chunks), encoding="utf-8", newline="\n")
|
|
try:
|
|
subprocess.run(
|
|
["npx", "--yes", LIGHTNINGCSS, str(input_path), "--minify", "--output-file", str(output)],
|
|
cwd=ROOT,
|
|
check=True,
|
|
)
|
|
finally:
|
|
input_path.unlink(missing_ok=True)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--check", action="store_true", help="fail if css/main.css is stale")
|
|
args = parser.parse_args()
|
|
if not SOURCE.is_file():
|
|
raise FileNotFoundError("Missing CSS source aggregator: css/source.css")
|
|
if args.check:
|
|
with tempfile.NamedTemporaryFile(prefix="msos-main-", suffix=".css", dir=ROOT / "css", delete=False) as tmp:
|
|
temp_output = Path(tmp.name)
|
|
try:
|
|
build(temp_output)
|
|
if temp_output.read_bytes() != OUTPUT.read_bytes():
|
|
print("css/main.css is stale; run tools/build_css_bundle.py", file=sys.stderr)
|
|
return 1
|
|
finally:
|
|
temp_output.unlink(missing_ok=True)
|
|
print(f"CSS bundle is current ({OUTPUT.stat().st_size:,} bytes)")
|
|
return 0
|
|
build(OUTPUT)
|
|
print(f"Wrote css/main.css ({OUTPUT.stat().st_size:,} bytes)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except Exception as exc:
|
|
print(f"CSS bundle failed: {exc}", file=sys.stderr)
|
|
raise SystemExit(1)
|