#!/usr/bin/env python3 """Bundle the v3 src/ tree into dist/theme-v3.css by following the @import graph rooted at src/entry.css. Design notes: - @import statements are inlined verbatim. v3 deliberately does NOT wrap imports in @layer because unlayered styles always beat layered ones, and Obsidian core CSS is unlayered. See src/entry.css for the full rationale. - Re-entrant imports are detected and refused. - The canonical bundle is dist/theme-v3.css; theme.css at the repo root is a copy promoted by dev/scripts/build_release.py or dev/scripts/sync_obsidian_theme.py. """ from __future__ import annotations import argparse import re from pathlib import Path ROOT = Path(__file__).resolve().parents[2] ENTRY = ROOT / "src" / "entry.css" DIST = ROOT / "dist" / "theme-v3.css" IMPORT_RE = re.compile( r"""@import\s+url\(\s*['"]?([^'")]+)['"]?\s*\)\s*;""", re.IGNORECASE, ) def is_commented_out(line: str) -> bool: stripped = line.strip() return stripped.startswith("/*") or stripped.startswith("//") def resolve_imports(file_path: Path, visited: set[Path]) -> str: real = file_path.resolve() if real in visited: raise RuntimeError(f"circular @import detected: {real}") visited = visited | {real} text = real.read_text(encoding="utf-8") out_lines: list[str] = [] for raw_line in text.splitlines(): # Only treat a line as an @import directive if the directive is at the # start of the line (ignoring whitespace) AND the line is not inside a # comment. Commented-out @import lines (placeholders for future steps) # must be passed through verbatim. if "@import" not in raw_line: out_lines.append(raw_line) continue if is_commented_out(raw_line): out_lines.append(raw_line) continue m = IMPORT_RE.search(raw_line) if not m: out_lines.append(raw_line) continue rel = m.group(1) target = (real.parent / rel).resolve() if not target.is_file(): raise FileNotFoundError(f"@import target not found: {rel} (from {real.relative_to(ROOT)})") inner = resolve_imports(target, visited) rel_display = target.relative_to(ROOT).as_posix() out_lines.append(f"/* >>> {rel_display} */") out_lines.append(inner) out_lines.append(f"/* <<< {rel_display} */") return "\n".join(out_lines) def bundle(entry: Path, dist: Path, dedup: bool = True, check: bool = False) -> int: if not entry.is_file(): print(f"ERROR: entry not found: {entry}") return 1 bundled = resolve_imports(entry, set()) dist.parent.mkdir(parents=True, exist_ok=True) header = ( "/* Owen Graphite v3 — bundled theme stylesheet.\n" " *\n" " * Generated by dev/scripts/bundle_v3.py from src/entry.css.\n" " * Do not hand-edit. The @import / cascade order is defined in src/entry.css.\n" " * Same-context duplicate-selector dedup pass: dev/scripts/dedup_v3.py.\n" " *\n" " * The repo root theme.css is a promoted copy of this bundle; see\n" " * dev/scripts/build_release.py and dev/scripts/sync_obsidian_theme.py.\n" " */\n\n" ) merges = 0 if dedup: # Import lazily so bundle_v3.py remains importable even if dedup_v3 # is removed/relocated. import importlib.util dedup_path = ROOT / "dev" / "scripts" / "dedup_v3.py" spec = importlib.util.spec_from_file_location("dedup_v3", dedup_path) if spec is None or spec.loader is None: raise RuntimeError("unable to load dev/scripts/dedup_v3.py") dedup_mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(dedup_mod) bundled, merges = dedup_mod.dedup_scope(bundled) bundled = "\n".join(line.rstrip() for line in bundled.splitlines()) new_content = header + bundled + "\n" line_count = bundled.count("\n") + 1 bang_count = bundled.count("!important") if check: # Determinism / freshness gate: compare against existing dist file. if not dist.is_file(): print(f"FAIL: {dist.relative_to(ROOT)} missing; run bundle_v3.py to generate.") return 1 current = dist.read_text(encoding="utf-8") if current != new_content: print( f"FAIL: {dist.relative_to(ROOT)} is stale vs src/.\n" f" Re-run: python dev/scripts/bundle_v3.py" ) return 1 print( f"OK: {dist.relative_to(ROOT)} matches src/ " f"({line_count} lines, !important={bang_count}, dedup_merges={merges})" ) return 0 dist.write_text(new_content, encoding="utf-8") print( f"OK: bundled {dist.relative_to(ROOT)} " f"({line_count} lines, !important={bang_count}, dedup_merges={merges})" ) return 0 def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--entry", type=Path, default=ENTRY) parser.add_argument("--out", type=Path, default=DIST) parser.add_argument("--no-dedup", action="store_true", help="Skip the post-bundle same-context selector dedup pass.") parser.add_argument("--check", action="store_true", help="Verify that dist matches a fresh bundle from src/; do not write.") args = parser.parse_args() return bundle(args.entry, args.out, dedup=not args.no_dedup, check=args.check) if __name__ == "__main__": raise SystemExit(main())