v0.34.9_css_refactor
This commit is contained in:
1 parent
2b7c67d755
commit
66ff9dfaa2
28 files changed
+11518
-13023
No files matched your search
Executable
+254
@@ -0,0 +1,254 @@
|
||||
#!/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())
|
||||
Reference in new issue
Block a user