Files
selfpost/docs/assets/panel-redesign/panel/check.py
T
mix 7ef7f75246 release: 1.9.2: Review pass: port-25 milters, DMARC validation, rate-limit fix
Review pass over `1.4.0`…`1.9.1`: port 25 no longer shares the submission milter chain, DMARC reports are validated before they are stored, and the auto rate limit is correct for a non-default level-1 window. Schema migration `0010` (index only). Upgrading is a tag bump.
Added:
  - SQLite migration `0010_send_log_app_login_index.sql` —
  - DMARC aggregate reports are accepted as **zip** as well as gzip or plain XML
  - e2e: `checkPort25ConfigCombinations` regenerates the Postfix configuration
Changed:
  - README hero line and a **Positioning** block in `docs/product.md` record the
Fixed:
  - The setup account's password is hashed at bcrypt cost `12` like every other
  - Login/setup rate limiter: protecting blocked buckets from eviction is now
  - `postqueue -p` output is bounded to 4 MiB (the timeout alone left the size of
  - [schema-migrations.md](docs/schema-migrations.md): the schema section was
  - postqueue reader is now time-bounded: `postqueue -p` runs under a 5-second
  … (11 more in CHANGELOG)
Changed:
  - [roadmap.md](docs/roadmap.md) now carries everything the 2026-08-19 code
  - Extracted `internal/configsafe` — the empty-value and forbidden-character
  - Build, deploy and migration comments no longer cite the archived
  - Dropped the `parseDomainRateLimitForm` / `parseAppRateLimitForm` pass-through
  - Extracted shared helpers: `internal/atomicfile` (atomic temp-write+rename) and
  … (7 more in CHANGELOG)
Removed:
  - Unused legacy asset `docs/assets/icon.png` (superseded by the four brand SVGs
  - Plan documents for already-shipped features: `domain-stats-auto-ratelimit`,

Full detail: CHANGELOG.md (1.9.2).
2026-09-22 15:24:53 +03:00

241 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Design-contract lint for the panel mockups, and the source of outlines.json.
The contract (docs/plans/panel-redesign.md) is only worth something if a
machine can say "no". This script is that machine for the mockups; the same
rules are ported to a Go test over internal/web templates when the redesign is
implemented, and that test compares rendered pages with outlines.json.
python docs/assets/panel-redesign/panel/check.py # lint
python docs/assets/panel-redesign/panel/check.py --write # lint + rewrite outlines.json
Exit code 1 on any violation.
"""
import json
import pathlib
import re
import sys
from html.parser import HTMLParser
HERE = pathlib.Path(__file__).parent
VOID = {"input", "img", "br", "hr", "meta", "link"}
# The subset of Bulma the panel uses. Reaching for a Bulma class outside this
# list is a design decision, not an implementation detail: add it here and to
# the components page in the same change, or do without it.
BULMA = set("""
box button buttons checkbox column columns control field file file-cta file-input file-label
file-name has-addons has-icons-left has-name has-text-danger help icon input label menu menu-label
menu-list notification progress select table table-container tag textarea title
is-3 is-4 is-5 is-6 is-7 is-9 is-active is-danger is-expanded is-fullwidth is-gapless is-half
is-hoverable is-left is-light is-primary is-small is-success is-text is-warning
mb-1 mb-2 mb-3 mb-4 ml-2 mt-4 mt-5
""".split())
# Classes the shell (build.py, later layout.html) owns; screens never write them.
SHELL = {"sp-page", "sp-perf", "sp-subnav", "sp-current", "sp-foot"}
# The signed-out screens have no shell, so they carry their own footer.
SIGNED_OUT = {"sp-signin", "sp-signin-brand", "sp-signin-form", "sp-foot"}
# Selectors allowed to set a width. Everything else gets its width from the
# layout it sits in — that is the whole point of the redesign.
WIDTH_OK = (".navbar-brand img", ".sp-head .sp-lead", ".sp-head .sp-grow", ".sp-postmark", ".table td.sp-clip",
".sp-h-icon", ".sp-signin", ".sp-timeline li::before", ".sp-perf")
class Node:
def __init__(self, tag, attrs, parent):
self.tag, self.attrs, self.parent, self.kids, self.text = tag, dict(attrs), parent, [], ""
@property
def classes(self):
return (self.attrs.get("class") or "").split()
def has(self, cls):
return cls in self.classes
def walk(self):
yield self
for k in self.kids:
yield from k.walk()
def find(self, cls):
return [n for n in self.walk() if n.has(cls)]
class Tree(HTMLParser):
def __init__(self):
super().__init__()
self.root = Node("root", [], None)
self.cur = self.root
def handle_starttag(self, tag, attrs):
n = Node(tag, attrs, self.cur)
self.cur.kids.append(n)
if tag not in VOID:
self.cur = n
def handle_endtag(self, tag):
n = self.cur
while n is not self.root and n.tag != tag:
n = n.parent
if n is not self.root:
self.cur = n.parent
def handle_data(self, data):
self.cur.text += data
def parse(text):
t = Tree()
t.feed(text)
return t.root
def text_of(node):
return re.sub(r"\s+", " ", "".join(n.text for n in node.walk())).strip()
def css_rules(css):
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
css = re.sub(r"@media[^{]*\{", "", css)
for sel, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css):
yield sel.strip(), body
def box_kind(box):
kinds = []
for k in box.kids:
if k.has("sp-box-head") or k.has("sp-box-foot"):
continue
for cls, name in (("sp-record", "record"), ("sp-health", "health"), ("sp-empty", "empty"),
("sp-log", "log"), ("sp-help-topic", "help"), ("sp-box-body", "body")):
if k.has(cls):
kinds.append(name)
break
else:
kinds.append("table" if k.tag == "table" or k.has("table-container") else k.tag)
out = []
for k in kinds:
if not out or out[-1].split("×")[0] != k:
out.append(k)
else:
n = int(out[-1].split("×")[1]) + 1 if "×" in out[-1] else 2
out[-1] = f"{k}×{n}"
return "+".join(out)
def outline(node):
"""The component skeleton of a page: what a reviewer sees with the text blurred."""
out = []
for k in node.kids:
if k.has("sp-head"):
parts = ["head"] + [p for p, c in (("postmark", "sp-postmark"), ("actions", "buttons")) if k.find(c)]
if not k.find("buttons") and any(n.tag in ("a", "form") and n.parent is k for n in k.walk()):
parts.append("actions")
out.append("+".join(parts))
elif k.has("box"):
variant = next((v for v in ("sp-danger-zone", "sp-credential") if k.has(v)), "")
head = k.find("sp-box-head")
title = text_of(head[0].find("sp-no")[0].parent.kids[1]) if head else "?"
out.append(f"box{'.' + variant if variant else ''}[{title}]:{box_kind(k)}")
elif k.has("columns"):
cols = []
for c in k.kids:
size = next((x for x in c.classes if re.fullmatch(r"is-\d+", x)), "auto")
cols.append({size: outline(c)})
out.append({"columns": cols})
elif k.tag == "form" or k.has("menu") or k.tag == "aside":
out.append({"form": outline(k)} if k.tag == "form" else "side_menu")
elif k.has("buttons"):
out.append("submit_row")
return out
def main():
errors = []
css = (HERE / "theme.css").read_text(encoding="utf-8")
defined = set(re.findall(r"\.(sp-[a-z0-9-]+)", css))
for sel, body in css_rules(css):
if "!important" in body:
errors.append(f"theme.css: !important in `{sel}`")
if re.search(r"(^|[\s,>+~])#[a-z]", sel):
errors.append(f"theme.css: id selector `{sel}` — style components, not one element on one page")
# The type floor: 13px. Icons (.ti) and the postmark imprint are not running text.
for value, unit in re.findall(r"font-size\s*:\s*([\d.]+)(rem|em|px)", body):
floor = {"rem": 0.8125, "em": 0.85, "px": 13}[unit]
if float(value) < floor and ".sp-postmark" not in sel and ".ti" not in sel:
errors.append(f"theme.css: `{sel}` sets font-size {value}{unit}, below the 13px floor — use --sp-fs-xs or --sp-fs-s")
# `min-width: 0` sizes nothing: it only lets a flex or grid child shrink so its text can be clipped.
sized = re.sub(r"min-width\s*:\s*0\s*(;|$)", "", body)
if re.search(r"(?<![-\w])(max-width|min-width|width)\s*:", sized) and not any(sel.startswith(w) or w in sel for w in WIDTH_OK):
errors.append(f"theme.css: `{sel}` sets a width; widths come from the page layout (WIDTH_OK)")
used, documented, outlines = set(), set(), {}
for src in sorted((HERE / "src").glob("*.html")):
text = src.read_text(encoding="utf-8")
name = src.stem
bare = "shell: bare" in text.split("-->")[0]
for bad in ("style=", "<style", "<script", " onclick=", " id=\"page-"):
if bad in text:
errors.append(f"{name}: `{bad}` is forbidden in a screen")
root = parse(text)
for n in root.walk():
for c in n.classes:
if c.startswith("sp-"):
(documented if name == "components" else used).add(c)
if c in SHELL and not (bare and c in SIGNED_OUT):
errors.append(f"{name}: `{c}` belongs to the shell")
elif c not in defined:
errors.append(f"{name}: `{c}` is not defined in theme.css")
elif c == "ti" or c.startswith("ti-"):
continue
elif c == "is-small" and "progress" not in n.classes:
errors.append(f"{name}: `is-small` on <{n.tag}> — controls keep the normal size; only a progress bar is small")
elif c not in BULMA:
errors.append(f"{name}: class `{c}` is neither in the Bulma subset nor an sp- component")
if bare:
continue
top = [k for k in root.kids if k.tag != "root"]
if not top or not top[0].has("sp-head"):
errors.append(f"{name}: the page must open with page_head (.sp-head)")
if len([n for n in root.walk() if n.tag == "h1"]) != 1:
errors.append(f"{name}: exactly one <h1>")
marks = root.find("sp-postmark")
if name != "components" and (len(marks) > 1 or any(not m.parent.has("sp-head") for m in marks)):
errors.append(f"{name}: at most one postmark, inside page_head")
for tbl in (n for n in root.walk() if n.tag == "table"):
if not tbl.parent.has("table-container"):
errors.append(f"{name}: a <table> must sit in a table-container so a wide one can scroll")
for box in root.find("box"):
if not box.kids or not box.kids[0].has("sp-box-head"):
errors.append(f"{name}: a box must start with box_head")
for k in top[1:]:
# A flash is conditional, so it is allowed at the top level but never part of an outline.
ok = k.has("box") or k.has("columns") or k.has("notification") or (k.tag == "form" and k.has("sp-form"))
if not ok:
errors.append(f"{name}: top-level <{k.tag} class=\"{' '.join(k.classes)}\"> — a page is boxes, columns or one form")
if name != "components":
outlines[name] = outline(root)
for c in sorted(used - documented - SIGNED_OUT):
errors.append(f"components page does not show `{c}` — document it or stop using it")
shell_used = set(re.findall(r"sp-[a-z0-9-]+", (HERE / "build.py").read_text(encoding="utf-8")))
for c in sorted(defined - used - documented - shell_used):
errors.append(f"theme.css defines `{c}` but nothing uses it")
if "--write" in sys.argv and not errors:
(HERE / "outlines.json").write_text(json.dumps(outlines, ensure_ascii=False, indent=1) + "\n",
encoding="utf-8", newline="\n")
golden = HERE / "outlines.json"
if golden.exists() and "--write" not in sys.argv:
if json.loads(golden.read_text(encoding="utf-8")) != outlines:
errors.append("outlines.json is stale — a screen's structure changed; re-run with --write and say so in the commit")
for e in errors:
print("FAIL", e)
print(f"{len(outlines) + 1} screens, {len(used | documented)} sp- classes, {len(errors)} violation(s)")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())