feat: add deterministic preview rendering
This commit is contained in:
parent
8c75f4f44d
commit
411f417670
23 changed files with 1413 additions and 142 deletions
135
src/docforge/render_config.py
Normal file
135
src/docforge/render_config.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"""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)),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue