2026-05-06 10:01:09 -05:00
|
|
|
"""Build phase — content prep for static-content sites."""
|
2026-05-06 08:07:28 -05:00
|
|
|
|
|
|
|
|
import shutil
|
|
|
|
|
import subprocess
|
|
|
|
|
import tempfile
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
2026-08-29 21:28:28 +00:00
|
|
|
from utils import EXCLUDE_FILES, env, parse_site_yaml, run_args
|
2026-05-06 08:07:28 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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}")
|
2026-08-29 21:28:28 +00:00
|
|
|
run_args(["hugo", "--source", str(src), "--destination", str(html_dir)])
|
2026-05-06 08:07:28 -05:00
|
|
|
|
|
|
|
|
elif cfg["type"] == "mkdocs":
|
|
|
|
|
print(f"Building MkDocs site from {src}")
|
2026-08-29 21:28:28 +00:00
|
|
|
run_args(["mkdocs", "build", "-d", str(html_dir)], cwd=src)
|
2026-05-06 08:07:28 -05:00
|
|
|
|
|
|
|
|
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}")
|
|
|
|
|
|
|
|
|
|
|
2026-08-29 21:28:28 +00:00
|
|
|
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}")
|
|
|
|
|
|
|
|
|
|
|
2026-05-06 08:07:28 -05:00
|
|
|
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
|
|
|
|
|
|
2026-08-29 21:28:28 +00:00
|
|
|
if cfg["mode"] == "multi":
|
|
|
|
|
stage_artifacts(site_dir, cfg)
|
|
|
|
|
else:
|
|
|
|
|
build_static(site_dir, cfg)
|