mirror of
https://github.com/towishy/Owen-Graphite.git
synced 2026-07-22 04:40:30 +00:00
141 lines
5.5 KiB
Python
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())
|