towishy_Owen-Graphite/dev/scripts/bundle_v3.py
2026-05-25 15:22:04 +09:00

141 lines
5.5 KiB
Python

#!/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(argv: list[str] | None = None) -> 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(argv)
return bundle(args.entry, args.out, dedup=not args.no_dedup, check=args.check)
if __name__ == "__main__":
raise SystemExit(main())