125 lines
4.1 KiB
Python
Executable File
125 lines
4.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
TEMPLATES_DIR = ROOT / "app" / "templates"
|
|
PAGES_DIR = TEMPLATES_DIR / "pages"
|
|
BASE_FILE = TEMPLATES_DIR / "base.html"
|
|
CSS_DIR = ROOT / "app" / "static" / "css"
|
|
|
|
RULE_EXTENDS = "POL001"
|
|
RULE_INLINE_STYLE = "POL002"
|
|
RULE_EXTRA_ASSETS = "POL003"
|
|
RULE_HEX_OUTSIDE_TOKENS = "POL004"
|
|
RULE_PX_SPACING = "POL005"
|
|
RULE_BASE_ASSETS = "POL006"
|
|
|
|
EXTENDS_BASE_RE = re.compile(r"\{%-?\s*extends\s+\"base\.html\"\s*-?%\}")
|
|
INLINE_STYLE_RE = re.compile(r"<style\b|style\s*=\s*\"", re.IGNORECASE)
|
|
ASSET_LINK_RE = re.compile(r"<link[^>]+rel=\"stylesheet\"|<script[^>]+src=\"", re.IGNORECASE)
|
|
HEX_RE = re.compile(r"#[0-9a-fA-F]{3,8}")
|
|
PX_SPACING_RE = re.compile(
|
|
r"(?:margin|padding|gap|row-gap|column-gap)\s*:\s*[^;]*\d+px",
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def err(errors: list[str], path: Path, line_no: int, rule: str, message: str) -> None:
|
|
rel = path.relative_to(ROOT)
|
|
errors.append(f"{rel}:{line_no}: {rule} {message}")
|
|
|
|
|
|
def check_base_assets(errors: list[str]) -> None:
|
|
if not BASE_FILE.exists():
|
|
errors.append(f"{BASE_FILE.relative_to(ROOT)}:1: {RULE_BASE_ASSETS} Missing base.html")
|
|
return
|
|
|
|
base_content = BASE_FILE.read_text(encoding="utf-8")
|
|
css_hits = re.findall(r'/static/css/[^\"]+', base_content)
|
|
js_hits = re.findall(r'/static/js/[^\"]+', base_content)
|
|
|
|
expected_css = ["/static/css/app.css?v={{ asset_version }}"]
|
|
expected_js = ["/static/js/app.js?v={{ asset_version }}"]
|
|
|
|
if css_hits != expected_css:
|
|
errors.append(
|
|
f"{BASE_FILE.relative_to(ROOT)}:1: {RULE_BASE_ASSETS} expected CSS include {expected_css}, found {css_hits}"
|
|
)
|
|
|
|
if js_hits != expected_js:
|
|
errors.append(
|
|
f"{BASE_FILE.relative_to(ROOT)}:1: {RULE_BASE_ASSETS} expected JS include {expected_js}, found {js_hits}"
|
|
)
|
|
|
|
|
|
def check_pages_extend_base(errors: list[str]) -> None:
|
|
page_files = sorted(PAGES_DIR.glob("*.html"))
|
|
if not page_files:
|
|
errors.append(f"{PAGES_DIR.relative_to(ROOT)}:1: {RULE_EXTENDS} No page templates found")
|
|
return
|
|
|
|
for path in page_files:
|
|
content = path.read_text(encoding="utf-8")
|
|
if not EXTENDS_BASE_RE.search(content):
|
|
err(
|
|
errors,
|
|
path,
|
|
1,
|
|
RULE_EXTENDS,
|
|
"page template must contain {% extends \"base.html\" %}",
|
|
)
|
|
|
|
|
|
def check_templates_inline_and_assets(errors: list[str]) -> None:
|
|
for path in sorted(TEMPLATES_DIR.rglob("*.html")):
|
|
for idx, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
|
if INLINE_STYLE_RE.search(line):
|
|
err(errors, path, idx, RULE_INLINE_STYLE, "inline style or <style> is forbidden")
|
|
|
|
if path != BASE_FILE and ASSET_LINK_RE.search(line):
|
|
err(
|
|
errors,
|
|
path,
|
|
idx,
|
|
RULE_EXTRA_ASSETS,
|
|
"asset includes are only allowed in templates/base.html",
|
|
)
|
|
|
|
|
|
def check_css_rules(errors: list[str]) -> None:
|
|
for path in sorted(CSS_DIR.rglob("*.css")):
|
|
is_tokens = path.name == "tokens.css"
|
|
for idx, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
|
if not is_tokens and HEX_RE.search(line):
|
|
err(errors, path, idx, RULE_HEX_OUTSIDE_TOKENS, "hex colors are only allowed in tokens.css")
|
|
|
|
if not is_tokens and PX_SPACING_RE.search(line):
|
|
err(errors, path, idx, RULE_PX_SPACING, "hard px spacing is forbidden outside tokens.css")
|
|
|
|
|
|
def main() -> int:
|
|
errors: list[str] = []
|
|
|
|
check_base_assets(errors)
|
|
check_pages_extend_base(errors)
|
|
check_templates_inline_and_assets(errors)
|
|
check_css_rules(errors)
|
|
|
|
if errors:
|
|
print("Policy checks failed:")
|
|
for item in errors:
|
|
print(f"{item}")
|
|
print("Hint: fix violations or move token values into app/static/css/tokens.css")
|
|
return 1
|
|
|
|
print("Policy checks passed")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|