136 lines
5.7 KiB
Python
136 lines
5.7 KiB
Python
|
|
"""Strict parsing and confinement for optional declared render views."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
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")
|
||
|
|
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")
|
||
|
|
if len(view_documents) > 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_document in view_documents:
|
||
|
|
if not isinstance(view_document, dict):
|
||
|
|
raise DocForgeError("invalid_config", "Each render view must be a table")
|
||
|
|
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)),
|
||
|
|
)
|