"""Build phase — content prep for static-content sites.""" import shutil import subprocess import tempfile from pathlib import Path from utils import EXCLUDE_FILES, env, parse_site_yaml, run_args def build_static(site_dir, cfg): build_dir = site_dir / "build" html_dir = build_dir / "html" if build_dir.exists(): shutil.rmtree(build_dir) content_dir = cfg["content_dir"] src = site_dir / content_dir if content_dir else site_dir if cfg["type"] == "static": print(f"Copying static content from {src}") with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) / "html" shutil.copytree(src, tmp_path, dirs_exist_ok=True) for name in EXCLUDE_FILES: p = tmp_path / name if p.is_dir(): shutil.rmtree(p) elif p.exists(): p.unlink() build_dir.mkdir(parents=True) shutil.move(str(tmp_path), str(html_dir)) elif cfg["type"] == "hugo": print(f"Building Hugo site from {src}") run_args(["hugo", "--source", str(src), "--destination", str(html_dir)]) elif cfg["type"] == "mkdocs": print(f"Building MkDocs site from {src}") run_args(["mkdocs", "build", "-d", str(html_dir)], cwd=src) if cfg.get("tidy", True): print("Running tidy on HTML files...") for html_file in html_dir.rglob("*.html"): subprocess.run( ["tidy", "-modify", "-quiet", "--wrap", "0", "--indent", "auto", "--indent-spaces", "2", "--drop-empty-elements", "no", "--tidy-mark", "no", "--show-warnings", "no", str(html_file)], check=False, ) print(f"Build complete — content at {html_dir}") def stage_artifacts(site_dir, cfg): """Snapshot caller-built outputs for the deploy phase.""" staging_dir = site_dir / ".site-publish" if staging_dir.is_symlink(): raise SystemExit("ERROR: .site-publish must not be a symbolic link") if staging_dir.exists(): shutil.rmtree(staging_dir) staging_dir.mkdir() root = site_dir.resolve() for name, artifact in cfg["artifacts"].items(): source = (site_dir / artifact["source"]).resolve() try: source.relative_to(root) except ValueError: raise SystemExit(f"ERROR: artifact {name}.source escapes the repository") if not source.is_dir(): raise SystemExit(f"ERROR: artifact {name}.source is not a directory: {artifact['source']}") files = [entry for entry in source.rglob("*") if entry.is_file()] if not files: raise SystemExit(f"ERROR: artifact {name}.source has no files: {artifact['source']}") for entry in source.rglob("*"): if entry.is_symlink(): try: entry.resolve(strict=True).relative_to(root) except (FileNotFoundError, ValueError): raise SystemExit( f"ERROR: artifact {name}.source contains an unsafe symlink: {entry.relative_to(source)}" ) destination = staging_dir / name destination.mkdir() routes = [route for route in cfg["routes"] if route["artifact"] == name] targets = set() print(f"Staging artifact {name} from {artifact['source']}") for route in routes: route_root = destination / route["path"].lstrip("/") for entry in files: relative = entry.relative_to(source) target = route_root / relative if target in targets: raise SystemExit( f"ERROR: artifact {name} routes map more than once to {target.relative_to(destination)}" ) targets.add(target) target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(entry, target, follow_symlinks=True) print(f"Build complete — staged artifacts at {staging_dir}") def cmd_build(): site_dir = Path(env("SITE_DIR")) cfg = parse_site_yaml(site_dir) if not cfg["enabled"]: print("Site disabled — skipping build") return if cfg["mode"] == "multi": stage_artifacts(site_dir, cfg) else: build_static(site_dir, cfg)