#!/usr/bin/env python3 """Build the embedded panel stylesheet in manifest order, without npm or imports. Only comments and formatting whitespace beside rule/declaration delimiters are minified. Selector whitespace, escapes, strings, functions (including calc and url), declaration values and rule order are otherwise retained verbatim. """ from __future__ import annotations import argparse import json from pathlib import Path import re import sys ROOT = Path(__file__).resolve().parents[2] CSS_DIRECTORY = Path("src/admin/web/css") FILENAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*\.css\Z") WHITESPACE = " \t\n\r\f" HEX = "0123456789abcdefABCDEF" DELIMITERS = frozenset("{};") def _escape_end(text: str, start: int) -> int: end = start + 1 if end == len(text): raise ValueError("Unterminated CSS escape") if text[end] in HEX: limit = min(end + 6, len(text)) while end < limit and text[end] in HEX: end += 1 if end < len(text) and text[end] in WHITESPACE: if text[end:end + 2] == "\r\n": return end + 2 end += 1 return end return end + (2 if text[end:end + 2] == "\r\n" else 1) def _string_end(text: str, start: int) -> int: end = start + 1 while end < len(text): if text[end] == text[start]: return end + 1 if text[end] == "\\": end = _escape_end(text, end) elif text[end] in "\n\r\f": raise ValueError("Unescaped newline in CSS string") else: end += 1 raise ValueError("Unterminated CSS string") def _comment_end(text: str, start: int) -> int: end = text.find("*/", start + 2) if end < 0: raise ValueError("Unterminated CSS comment") return end + 2 def _group_end(text: str, start: int) -> int: # Do not rewrite anything in functions or attribute selectors. This also # protects unquoted data URLs and whitespace required by calc()/CSS escapes. closing = ")" if text[start] == "(" else "]" is_url = (text[start] == "(" and text[max(0, start - 3):start].lower() == "url" and (start < 4 or not (text[start - 4].isalnum() or text[start - 4] in "_-"))) end = start + 1 while end < len(text): if text[end] == closing: return end + 1 if text[end] in "\"'": end = _string_end(text, end) elif text[end] == "\\": end = _escape_end(text, end) elif text.startswith("/*", end) and not is_url: end = _comment_end(text, end) elif text[end] in "([" and not is_url: end = _group_end(text, end) elif text[end] in ")]" and not is_url: raise ValueError("Mismatched CSS delimiter") else: # In an unquoted url(), /* and square brackets are literal data. end += 1 raise ValueError(f"Unterminated CSS {text[start]} group") def minify(text: str) -> str: """Conservatively compact a stylesheet without reordering its CSS tokens.""" tokens: list[tuple[str, str]] = [] position = 0 depth = 0 while position < len(text): char = text[position] if char in WHITESPACE: end = position + 1 while end < len(text) and text[end] in WHITESPACE: end += 1 kind = "space" elif text.startswith("/*", position): end, kind = _comment_end(text, position), "comment" elif char in "\"'": end, kind = _string_end(text, position), "protected" elif char == "\\": end, kind = _escape_end(text, position), "protected" elif char in "([": end, kind = _group_end(text, position), "protected" else: end, kind = position + 1, "plain" if char == "{": depth += 1 elif char == "}": depth -= 1 if depth < 0: raise ValueError("Unexpected closing CSS brace") elif char in ")]": raise ValueError("Unexpected closing CSS delimiter") tokens.append((kind, text[position:end])) position = end if depth: raise ValueError("Unterminated CSS rule") # Source modules must stay independent of runtime imports and encoding # declarations; all are UTF-8 and are delivered as one embedded response. for index, token in enumerate(tokens): if token != ("plain", "@"): continue name = "" for offset in range(index + 1, len(tokens)): kind, value = tokens[offset] if kind == "plain" and (value.isalnum() or value in "_-"): name += value elif kind == "protected" and value.startswith("\\"): escaped = value[1:] if escaped and escaped[0] in HEX: codepoint = int(escaped.rstrip(WHITESPACE), 16) name += chr(codepoint) if 0 < codepoint <= 0x10FFFF else "\ufffd" else: name += escaped else: break if name.lower() in ("import", "charset"): raise ValueError("CSS modules must not contain @import or @charset") compact: list[tuple[str, str]] = [] for index, (kind, value) in enumerate(tokens): if kind == "comment": if value.startswith("/*!"): compact.append(("protected", value)) continue before = tokens[index - 1] if index else None after = tokens[index + 1] if index + 1 < len(tokens) else None if (before and after and before[0] != "space" and after[0] != "space" and not (before[0] == "plain" and before[1] in DELIMITERS) and not (after[0] == "plain" and after[1] in DELIMITERS)): # Retain a token boundary: e.g. --value:foo/**/bar must never # become the single identifier foobar. No guessed whitespace. compact.append(("protected", "/**/")) continue if kind == "space" and compact and compact[-1][0] == "space": compact[-1] = ("space", compact[-1][1] + value) else: compact.append((kind, value)) output: list[str] = [] for index, (kind, value) in enumerate(compact): if kind == "space": before = compact[index - 1] if index else None after = compact[index + 1] if index + 1 < len(compact) else None if (not before or not after or (before[0] == "plain" and before[1] in DELIMITERS) or (after[0] == "plain" and after[1] in DELIMITERS)): continue output.append(value) return "".join(output) def source_files(root: Path = ROOT) -> list[Path]: directory = root / CSS_DIRECTORY manifest = directory / "manifest.json" if directory.is_symlink() or manifest.is_symlink(): raise ValueError("Panel CSS directory and manifest must not be symlinks") config = json.loads(manifest.read_text(encoding="utf-8")) if not isinstance(config, dict) or set(config) != {"files"}: raise ValueError('CSS manifest must be an object containing only a "files" array') names = config["files"] if (not isinstance(names, list) or not names or not all(isinstance(name, str) and FILENAME.fullmatch(name) for name in names)): raise ValueError("CSS manifest must list CSS basenames without directory components") if len(names) != len(set(names)): raise ValueError("CSS manifest lists a source more than once") actual = {path.name for path in directory.iterdir() if path.suffix.lower() == ".css"} if set(names) != actual: missing = sorted(set(names) - actual) unlisted = sorted(actual - set(names)) raise ValueError(f"CSS manifest mismatch: missing={missing}; unlisted={unlisted}") paths = [directory / name for name in names] for path in paths: if path.is_symlink() or not path.is_file(): raise ValueError(f"CSS source must be a regular, non-symlink file: {path.name}") return paths def bundle(root: Path = ROOT) -> tuple[str, list[Path], int]: paths = source_files(root) parts: list[str] = [] source_bytes = 0 for path in paths: raw = path.read_bytes() source_bytes += len(raw) try: parts.append(minify(raw.decode("utf-8"))) except ValueError as error: raise ValueError(f"{path.name}: {error}") from error return "\n".join(parts) + "\n", paths, source_bytes def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=ROOT) mode = parser.add_mutually_exclusive_group(required=True) mode.add_argument("--out-dir", type=Path, help="Write panel-style.css here") mode.add_argument("--check", action="store_true", help="Validate sources without writing files") parser.add_argument("--cargo", action="store_true", help="Emit Cargo input/output dependencies") args = parser.parse_args() try: root = args.root.resolve() result, paths, source_bytes = bundle(root) encoded = result.encode("utf-8") if args.out_dir is not None: args.out_dir.mkdir(parents=True, exist_ok=True) destination = args.out_dir / "panel-style.css" if not destination.exists() or destination.read_bytes() != encoded: destination.write_bytes(encoded) if args.cargo: watched = [root / CSS_DIRECTORY, root / CSS_DIRECTORY / "manifest.json", *paths] if args.out_dir is not None: # Cargo must rerun the generator when its cached output is # removed even if every source module is unchanged. watched.append(args.out_dir.resolve() / "panel-style.css") for path in watched: if any(char in str(path) for char in "\r\n\0"): raise ValueError("CSS build dependency path contains a control character") print(f"cargo:rerun-if-changed={path}") else: print(f"Panel CSS: {len(paths)} modules, {source_bytes} source bytes, " f"{len(encoded)} bundled bytes; cascade order preserved.") except (OSError, ValueError) as error: print(f"Panel CSS preparation failed: {error}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main())