"""Strict parsing and confinement for optional declared render views.""" from __future__ import annotations from pathlib import Path from typing import cast from .config_validation import ID_PATTERN, confined_path, require_string, string_list from .errors import DocForgeError from .models import Limits, RenderConfig, RenderView _RENDER_KEYS = frozenset({"template_root", "preview_root", "views"}) _VIEW_KEYS = frozenset({"id", "renderer", "template", "output", "title", "families"}) _RENDERERS = frozenset({"generic_html"}) def _paths_overlap(first: Path, second: Path) -> bool: return first == second or first.is_relative_to(second) or second.is_relative_to(first) def load_render_config( root: Path, document: object, *, descriptor_path: Path, content_roots: tuple[Path, ...], authority_files: tuple[Path, ...], cache_root: Path, index_path: Path, changeset_root: Path, limits: Limits, ) -> RenderConfig | None: if document is None: return None if not isinstance(document, dict): raise DocForgeError("invalid_config", "render must be a table") document = cast(dict[str, object], document) unknown = sorted(set(document) - _RENDER_KEYS) if unknown: raise DocForgeError("invalid_config", "render has unknown fields", fields=unknown) template_root = confined_path( root, document.get("template_root"), field="render.template_root", must_exist=True, expected="directory", ) preview_root = confined_path( root, document.get("preview_root"), field="render.preview_root", must_exist=False, ) template_protected = (*content_roots, cache_root, changeset_root) if any(_paths_overlap(template_root, path) for path in template_protected): raise DocForgeError( "invalid_config", "Template input must not overlap canonical or derived roots" ) if any(path == template_root or path.is_relative_to(template_root) for path in authority_files): raise DocForgeError("invalid_config", "Template input must not contain authority files") protected_roots = (*content_roots, cache_root, changeset_root, template_root) if any(_paths_overlap(preview_root, path) for path in protected_roots): raise DocForgeError("invalid_config", "Preview output must not overlap other project roots") if any(path == preview_root or path.is_relative_to(preview_root) for path in authority_files): raise DocForgeError("invalid_config", "Preview output must not contain authority files") view_documents = document.get("views") if not isinstance(view_documents, list) or not view_documents: raise DocForgeError("invalid_config", "render.views must contain at least one view") view_values = cast(list[object], view_documents) if len(view_values) > limits.max_render_views: raise DocForgeError("invalid_config", "render.views exceeds the configured limit") views: list[RenderView] = [] view_ids: set[str] = set() output_paths: set[Path] = set() for view_value in view_values: if not isinstance(view_value, dict): raise DocForgeError("invalid_config", "Each render view must be a table") view_document = cast(dict[str, object], view_value) unknown_view = sorted(set(view_document) - _VIEW_KEYS) if unknown_view: raise DocForgeError( "invalid_config", "Render view has unknown fields", fields=unknown_view ) view_id = require_string(view_document, "id", descriptor_path) if ID_PATTERN.fullmatch(view_id) is None or view_id in view_ids: raise DocForgeError( "invalid_config", "Render view ID is invalid or duplicated", id=view_id ) view_ids.add(view_id) renderer = require_string(view_document, "renderer", descriptor_path) if renderer not in _RENDERERS: raise DocForgeError( "unsupported_renderer", "Render view names an unsupported built-in renderer", renderer=renderer, ) template = confined_path( template_root, view_document.get("template"), field="render.view.template", must_exist=True, expected="file", ) output = confined_path( root, view_document.get("output"), field="render.view.output", must_exist=False, ) if output.suffix != ".html": raise DocForgeError("invalid_config", "generic_html output must use an .html file") if output in output_paths: raise DocForgeError("invalid_config", "Render view outputs must be unique") output_paths.add(output) forbidden_outputs = (*content_roots, changeset_root, preview_root, template_root) if any(output == path or output.is_relative_to(path) for path in forbidden_outputs): raise DocForgeError("invalid_config", "Render output overlaps a protected project root") if output in authority_files or output in {descriptor_path, index_path}: raise DocForgeError("invalid_config", "Render output overlaps a protected project file") views.append( RenderView( view_id=view_id, renderer=renderer, template_path=template, output_path=output, title=require_string(view_document, "title", descriptor_path), families=string_list( view_document.get("families", []), key="render.view.families", source=descriptor_path, ), ) ) return RenderConfig( template_root=template_root, preview_root=preview_root, views=tuple(sorted(views, key=lambda view: view.view_id)), )