mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 15:41:41 +02:00
231 lines
8.7 KiB
Python
231 lines
8.7 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
import shutil
|
|
|
|
from .config import GatewayConfig, ProtocolConfig, SiteConfig
|
|
from .renderers import PrivacyRenderStats, render_gemtext, render_micron, render_privacy_html
|
|
|
|
|
|
IGNORED_DIRS = {".git", ".hg", ".svn", ".vscode", "__pycache__"}
|
|
IGNORED_SUFFIXES = (".bak", ".tmp", ".swp")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BuildResult:
|
|
site_id: str
|
|
protocol: str
|
|
renderer: str
|
|
output_root: Path
|
|
files_written: int
|
|
files_skipped: int = 0
|
|
privacy_stats: PrivacyRenderStats | None = None
|
|
|
|
|
|
def build_gateway(config: GatewayConfig) -> list[BuildResult]:
|
|
results: list[BuildResult] = []
|
|
for site in config.sites:
|
|
_validate_source(site)
|
|
for protocol in site.protocols.values():
|
|
if not protocol.enabled:
|
|
continue
|
|
results.append(build_protocol(site, protocol))
|
|
return results
|
|
|
|
|
|
def build_protocol(site: SiteConfig, protocol: ProtocolConfig) -> BuildResult:
|
|
output_root = site.outputs.root / protocol.name
|
|
_prepare_output_root(output_root)
|
|
if protocol.renderer == "clearnet":
|
|
return _copy_clearnet(site, protocol, output_root)
|
|
if protocol.renderer == "privacy-html":
|
|
return _render_privacy_html_tree(site, protocol, output_root)
|
|
if protocol.renderer == "gemtext":
|
|
return _render_gemtext_tree(site, protocol, output_root)
|
|
if protocol.renderer == "micron":
|
|
return _render_micron_tree(site, protocol, output_root)
|
|
raise ValueError(f"{site.id}/{protocol.name}: unsupported renderer {protocol.renderer!r}")
|
|
|
|
|
|
def _copy_clearnet(site: SiteConfig, protocol: ProtocolConfig, output_root: Path) -> BuildResult:
|
|
files_written = 0
|
|
for source_path in _iter_source_files(site.source.path):
|
|
target_path = output_root / source_path.relative_to(site.source.path)
|
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source_path, target_path)
|
|
files_written += 1
|
|
return BuildResult(
|
|
site_id=site.id,
|
|
protocol=protocol.name,
|
|
renderer=protocol.renderer,
|
|
output_root=output_root,
|
|
files_written=files_written,
|
|
)
|
|
|
|
|
|
def _render_privacy_html_tree(
|
|
site: SiteConfig, protocol: ProtocolConfig, output_root: Path
|
|
) -> BuildResult:
|
|
files_written = 0
|
|
files_skipped = 0
|
|
stats = PrivacyRenderStats()
|
|
max_asset_bytes = int(protocol.options.get("max_asset_bytes", 1024 * 1024))
|
|
script_policy = str(protocol.options.get("script_policy", "strip"))
|
|
if script_policy != "strip":
|
|
raise ValueError(
|
|
f"{site.id}/{protocol.name}: privacy-html only supports script_policy='strip'"
|
|
)
|
|
for source_path in _iter_source_files(site.source.path):
|
|
target_path = output_root / source_path.relative_to(site.source.path)
|
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
if source_path.suffix.lower() in {".html", ".htm"}:
|
|
html = source_path.read_text(encoding="utf-8", errors="replace")
|
|
rendered, file_stats = render_privacy_html(html)
|
|
target_path.write_text(rendered, encoding="utf-8")
|
|
stats = stats.add(file_stats)
|
|
elif source_path.suffix.lower() in {".js", ".map"}:
|
|
files_skipped += 1
|
|
continue
|
|
elif source_path.stat().st_size > max_asset_bytes:
|
|
files_skipped += 1
|
|
continue
|
|
else:
|
|
shutil.copy2(source_path, target_path)
|
|
files_written += 1
|
|
return BuildResult(
|
|
site_id=site.id,
|
|
protocol=protocol.name,
|
|
renderer=protocol.renderer,
|
|
output_root=output_root,
|
|
files_written=files_written,
|
|
files_skipped=files_skipped,
|
|
privacy_stats=stats,
|
|
)
|
|
|
|
|
|
def _render_gemtext_tree(
|
|
site: SiteConfig, protocol: ProtocolConfig, output_root: Path
|
|
) -> BuildResult:
|
|
files_written = 0
|
|
files_skipped = 0
|
|
max_asset_bytes = int(protocol.options.get("max_asset_bytes", 1024 * 1024))
|
|
for source_path in _iter_source_files(site.source.path):
|
|
if source_path.suffix.lower() in {".html", ".htm"}:
|
|
target_path = _target_with_suffix(site.source.path, output_root, source_path, ".gmi")
|
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
html = source_path.read_text(encoding="utf-8", errors="replace")
|
|
target_path.write_text(render_gemtext(html, rewrite_link=_rewrite_gemtext_link), encoding="utf-8")
|
|
elif source_path.suffix.lower() in {".js", ".css", ".map"}:
|
|
files_skipped += 1
|
|
continue
|
|
elif source_path.stat().st_size > max_asset_bytes:
|
|
files_skipped += 1
|
|
continue
|
|
else:
|
|
target_path = output_root / source_path.relative_to(site.source.path)
|
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source_path, target_path)
|
|
files_written += 1
|
|
return BuildResult(
|
|
site_id=site.id,
|
|
protocol=protocol.name,
|
|
renderer=protocol.renderer,
|
|
output_root=output_root,
|
|
files_written=files_written,
|
|
files_skipped=files_skipped,
|
|
)
|
|
|
|
|
|
def _render_micron_tree(
|
|
site: SiteConfig, protocol: ProtocolConfig, output_root: Path
|
|
) -> BuildResult:
|
|
files_written = 0
|
|
files_skipped = 0
|
|
max_asset_bytes = int(protocol.options.get("max_asset_bytes", 512 * 1024))
|
|
for source_path in _iter_source_files(site.source.path):
|
|
if source_path.suffix.lower() in {".html", ".htm"}:
|
|
target_path = _target_with_suffix(site.source.path, output_root, source_path, ".mu")
|
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
html = source_path.read_text(encoding="utf-8", errors="replace")
|
|
target_path.write_text(render_micron(html, rewrite_link=_rewrite_micron_link), encoding="utf-8")
|
|
elif source_path.suffix.lower() in {".js", ".css", ".map"}:
|
|
files_skipped += 1
|
|
continue
|
|
elif source_path.stat().st_size > max_asset_bytes:
|
|
files_skipped += 1
|
|
continue
|
|
else:
|
|
target_path = output_root / source_path.relative_to(site.source.path)
|
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source_path, target_path)
|
|
files_written += 1
|
|
return BuildResult(
|
|
site_id=site.id,
|
|
protocol=protocol.name,
|
|
renderer=protocol.renderer,
|
|
output_root=output_root,
|
|
files_written=files_written,
|
|
files_skipped=files_skipped,
|
|
)
|
|
|
|
|
|
def _iter_source_files(root: Path):
|
|
for path in sorted(root.rglob("*")):
|
|
if not path.is_file():
|
|
continue
|
|
rel_parts = path.relative_to(root).parts
|
|
if any(part in IGNORED_DIRS for part in rel_parts):
|
|
continue
|
|
if any(part.startswith(".") and part != ".well-known" for part in rel_parts):
|
|
continue
|
|
if path.name.endswith(IGNORED_SUFFIXES) or ".bak." in path.name:
|
|
continue
|
|
yield path
|
|
|
|
|
|
def _validate_source(site: SiteConfig) -> None:
|
|
if not site.source.path.exists():
|
|
raise FileNotFoundError(f"{site.id}: source path does not exist: {site.source.path}")
|
|
if not site.source.path.is_dir():
|
|
raise NotADirectoryError(f"{site.id}: source path is not a directory: {site.source.path}")
|
|
|
|
|
|
def _prepare_output_root(output_root: Path) -> None:
|
|
marker = output_root / ".ampg-output"
|
|
if output_root.exists() and any(output_root.iterdir()) and not marker.exists():
|
|
raise RuntimeError(
|
|
f"refusing to clean non-empty unmarked output directory: {output_root}"
|
|
)
|
|
if marker.exists():
|
|
shutil.rmtree(output_root)
|
|
output_root.mkdir(parents=True, exist_ok=True)
|
|
marker.write_text("generated by AMPG\n", encoding="utf-8")
|
|
|
|
|
|
def _target_with_suffix(source_root: Path, output_root: Path, source_path: Path, suffix: str) -> Path:
|
|
rel_path = source_path.relative_to(source_root)
|
|
return (output_root / rel_path).with_suffix(suffix)
|
|
|
|
|
|
def _rewrite_gemtext_link(href: str) -> str:
|
|
if href.startswith("#"):
|
|
return href
|
|
if href.startswith(("http://", "https://", "gemini://", "mailto:")):
|
|
return href
|
|
path, sep, fragment = href.partition("#")
|
|
if path.endswith(".html") or path.endswith(".htm"):
|
|
path = path.rsplit(".", 1)[0] + ".gmi"
|
|
return path + (sep + fragment if sep else "")
|
|
|
|
|
|
def _rewrite_micron_link(href: str) -> str:
|
|
if href.startswith("#"):
|
|
return href
|
|
if href.startswith(("http://", "https://", "gemini://", "mailto:")):
|
|
return href
|
|
path, sep, fragment = href.partition("#")
|
|
if path.endswith(".html") or path.endswith(".htm"):
|
|
path = path.rsplit(".", 1)[0] + ".mu"
|
|
return path + (sep + fragment if sep else "")
|