#!/usr/bin/env python3 from __future__ import annotations import argparse import re from pathlib import Path ROOT = Path(__file__).resolve().parents[1] DEFAULT_OUTPUT = ROOT / "karldns" / "assets" / "bootstrap-subset.css" SCAN_PATHS = [ ROOT / "karldns" / "web", ROOT / "karldns" / "routers", ] CSS = r""" :root{--bs-body-color:#000;--bs-body-bg:#fff;--bs-border-color:#e5e5e5;--bs-secondary-color:#737373}[data-bs-theme="dark"]{--bs-body-color:#f5f5f5;--bs-body-bg:#0f0f0f;--bs-border-color:rgba(255,255,255,.16);--bs-secondary-color:#a3a3a3}html{-webkit-text-size-adjust:100%}body{margin:0}button,input,select{margin:0;font:inherit}button,select{text-transform:none}button:not(:disabled),[type="button"]:not(:disabled),[type="submit"]:not(:disabled){cursor:pointer}a{color:inherit}img,svg{vertical-align:middle}code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}pre{margin:0}.container{width:100%;margin-right:auto;margin-left:auto;padding-right:12px;padding-left:12px}.row{display:flex;flex-wrap:wrap;margin-right:calc(-.5 * var(--bs-gutter-x,0));margin-left:calc(-.5 * var(--bs-gutter-x,0));margin-top:calc(-1 * var(--bs-gutter-y,0))}.row>*{width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x,0) * .5);padding-left:calc(var(--bs-gutter-x,0) * .5);margin-top:var(--bs-gutter-y,0)}.col-6{flex:0 0 auto;width:50%}.col-12{flex:0 0 auto;width:100%}@media (min-width:768px){.col-md-6{flex:0 0 auto;width:50%}.align-items-md-center{align-items:center!important}.flex-md-row{flex-direction:row!important}}@media (min-width:992px){.col-lg-3{flex:0 0 auto;width:25%}.col-lg-5{flex:0 0 auto;width:41.666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.333333%}.flex-lg-row{flex-direction:row!important}}@media (min-width:1200px){.col-xl-3{flex:0 0 auto;width:25%}}.g-3{--bs-gutter-x:16px;--bs-gutter-y:16px}.g-4{--bs-gutter-x:24px;--bs-gutter-y:24px}.d-flex{display:flex!important}.d-grid{display:grid!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-wrap{flex-wrap:wrap!important}.flex-shrink-0{flex-shrink:0!important}.align-items-center{align-items:center!important}.align-items-start{align-items:flex-start!important}.align-items-stretch{align-items:stretch!important}.align-self-start{align-self:flex-start!important}.justify-content-between{justify-content:space-between!important}.justify-content-center{justify-content:center!important}.justify-content-end{justify-content:flex-end!important}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.gap-sm-3{gap:16px!important}}.gap-2{gap:8px!important}.gap-3{gap:16px!important}.gap-4{gap:24px!important}.m-0{margin:0!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.ms-auto{margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-3{margin-top:16px!important;margin-bottom:16px!important}.mt-1{margin-top:4px!important}.mt-2{margin-top:8px!important}.mt-3{margin-top:16px!important}.mt-4{margin-top:24px!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:4px!important}.mb-2{margin-bottom:8px!important}.mb-3{margin-bottom:16px!important}.mb-4{margin-bottom:24px!important}.p-0{padding:0!important}.p-4{padding:24px!important}.px-0{padding-right:0!important;padding-left:0!important}.px-2{padding-right:8px!important;padding-left:8px!important}.px-3{padding-right:16px!important;padding-left:16px!important}.py-2{padding-top:8px!important;padding-bottom:8px!important}.w-100{width:100%!important}.h-100{height:100%!important}.text-center{text-align:center!important}.text-start{text-align:left!important}.text-nowrap{white-space:nowrap!important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-secondary{color:var(--bs-secondary-color)!important}.text-danger{color:var(--rp-danger)!important}.small{font-size:.875em!important}.h5{font-size:1.25rem}.opacity-75{opacity:.75!important}.rounded{border-radius:12px!important}.rounded-0{border-radius:0!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:9999px!important}.border{border:1px solid var(--bs-border-color)!important}.border-top{border-top:1px solid var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-end-0{border-right:0!important}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.navbar{position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between}.navbar-brand{display:inline-block;padding-top:5px;padding-bottom:5px;margin-right:16px;text-decoration:none;white-space:nowrap}.navbar-nav{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-text{padding-top:8px;padding-bottom:8px}.nav-link{display:block;padding:8px;text-decoration:none}.btn{--bs-btn-color:#000;--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-btn-color);--bs-btn-hover-bg:var(--bs-btn-bg);--bs-btn-hover-border-color:var(--bs-btn-border-color);--bs-btn-active-color:var(--bs-btn-hover-color);--bs-btn-active-bg:var(--bs-btn-hover-bg);--bs-btn-active-border-color:var(--bs-btn-hover-border-color);color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border:1px solid var(--bs-btn-border-color);text-decoration:none;user-select:none}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn:disabled,.btn.disabled{pointer-events:none;opacity:.65}.form-label{margin-bottom:8px}.form-control,.form-select{display:block;appearance:none}.form-select{padding:8px 40px 8px 16px}.form-select-sm{min-height:34px;padding-top:4px;padding-bottom:4px;font-size:.875rem}.form-check{display:block;min-height:24px}.form-check-input{width:1em;height:1em;margin:0}.form-check-label{color:inherit;font-size:inherit}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group-text{display:flex;align-items:center;padding:10px 16px;border:1px solid var(--bs-border-color)}.card{position:relative;min-width:0}.card-body{flex:1 1 auto}.flat-card{box-shadow:none}.badge{display:inline-block;padding:4px 8px;font-size:.75em;font-weight:600;line-height:1;text-align:center;white-space:nowrap}.alert{position:relative;padding:16px;border:1px solid transparent}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}.table{width:100%;margin-bottom:16px;vertical-align:top;border-collapse:collapse}.table>:not(caption)>*>*{padding:8px}.pagination{display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;min-height:36px;padding:8px 12px;color:var(--rp-ink);background:var(--rp-surface);border:1px solid var(--rp-line)}.page-item:first-child .page-link{border-top-left-radius:999px;border-bottom-left-radius:999px}.page-item:last-child .page-link{border-top-right-radius:999px;border-bottom-right-radius:999px}.page-item.active .page-link{color:#fff;background:#000;border-color:#000}.page-item.disabled .page-link,.page-link:disabled{color:var(--rp-muted);pointer-events:none;background:var(--rp-field)}.pagination-sm .page-link{min-height:32px;padding:6px 10px;font-size:.875rem}.visually-hidden{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.empty-row{} """.strip().replace( ".col-lg-3{flex:0 0 auto;width:25%}", ".col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.333333%}", ) SUPPORTED_DYNAMIC_CLASSES = {"active", "disabled", "page-item", "page-link"} def _class_names_from_css(css: str) -> set[str]: return set(re.findall(r"\.([_a-zA-Z][_a-zA-Z0-9-]*)", css)) def _class_names_from_templates() -> set[str]: class_names: set[str] = set() for scan_path in SCAN_PATHS: for path in scan_path.glob("*.py"): text = path.read_text() for match in re.finditer(r"class=[\"']([^\"']+)[\"']", text): for class_name in match.group(1).split(): if "{" not in class_name and "}" not in class_name: class_names.add(class_name) return class_names def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Build and validate the tiny KarlDNS Bootstrap compatibility subset.") parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) return parser.parse_args() def main() -> int: args = parse_args() output = args.output if args.output.is_absolute() else ROOT / args.output app_styles = (ROOT / "karldns" / "web" / "styles.py").read_text() supported = _class_names_from_css(CSS) | _class_names_from_css(app_styles) | SUPPORTED_DYNAMIC_CLASSES unsupported = sorted(_class_names_from_templates() - supported) if unsupported: unsupported_text = "\n".join(f"- {class_name}" for class_name in unsupported) raise SystemExit(f"Unsupported Bootstrap-style classes in templates:\n{unsupported_text}") output.write_text(CSS + "\n") try: display_path = output.relative_to(ROOT) except ValueError: display_path = output print(f"Wrote {display_path} ({output.stat().st_size} bytes)") return 0 if __name__ == "__main__": raise SystemExit(main())