diff --git a/betty/jinja2/__init__.py b/betty/jinja2/__init__.py index 800640be7..47501fac4 100644 --- a/betty/jinja2/__init__.py +++ b/betty/jinja2/__init__.py @@ -10,10 +10,18 @@ Mapping, ) from threading import Lock -from typing import Callable, Any, cast, TYPE_CHECKING, TypeAlias, final, Awaitable, Self +from typing import ( + Callable, + Any, + cast, + TYPE_CHECKING, + TypeAlias, + final, + Awaitable, + Self, +) import aiofiles -from aiofiles import os as aiofiles_os from jinja2 import ( Environment as Jinja2Environment, select_autoescape, @@ -24,7 +32,7 @@ from jinja2.runtime import StrictUndefined, Context, DebugUndefined from typing_extensions import override -from betty import model +from betty import model, job from betty.asyncio import wait_to_thread from betty.html import CssProvider, JsProvider from betty.jinja2.filter import FILTERS @@ -34,19 +42,20 @@ from betty.locale.localizable import Localizable, plain from betty.locale.localizer import DEFAULT_LOCALIZER from betty.locale.localizer import Localizer -from betty.plugin import Plugin +from betty.media_type.media_types import JINJA2_HTML, HTML from betty.project.factory import ProjectDependentFactory -from betty.render import Renderer +from betty.render import RendererPlugin, MediaTypey from betty.serde.dump import Dumpable, DumpMapping, VoidableDump, Dump from betty.typing import Void +from pathlib import Path if TYPE_CHECKING: + from betty.media_type import MediaType from betty.machine_name import MachineName from betty.model import Entity from betty.project.extension import Extension from betty.project import ProjectConfiguration, Project from betty.ancestry import Citation - from pathlib import Path from collections.abc import ( MutableMapping, Iterator, @@ -396,7 +405,7 @@ def _init_extensions(self) -> None: @final -class Jinja2Renderer(Renderer, ProjectDependentFactory, Plugin): +class Jinja2Renderer(RendererPlugin, ProjectDependentFactory): """ Render content as Jinja2 templates. """ @@ -422,41 +431,47 @@ async def new_for_project(cls, project: Project) -> Self: @override @property - def file_extensions(self) -> set[str]: - return {".j2"} + def media_types(self) -> Mapping[MediaType, MediaType]: + return {JINJA2_HTML: HTML} @override - async def render_file( + async def render( self, - file_path: Path, + content: str, + media_typey: MediaTypey, *, - job_context: JobContext | None = None, + job_context: job.Context | None = None, localizer: Localizer | None = None, - ) -> Path: - destination_file_path = file_path.parent / file_path.stem + ) -> tuple[str, MediaType]: + to_media_type = self._assert_to_media_type(media_typey) data: MutableMapping[str, Any] = {} if job_context is not None: data["job_context"] = job_context if localizer is not None: data["localizer"] = localizer - try: - relative_file_destination_path = destination_file_path.relative_to( - self._configuration.www_directory_path - ) - except ValueError: - pass + if isinstance(media_typey, Path): + try: + relative_file_destination_path = media_typey.relative_to( + self._configuration.www_directory_path + ) + except ValueError: + pass + else: + resource = "/".join(relative_file_destination_path.parts) + if self._configuration.locales.multilingual: + resource_parts = resource.lstrip("/").split("/") + if resource_parts[0] in ( + x.alias for x in self._configuration.locales.values() + ): + resource = "/".join(resource_parts[1:]) + data["page_resource"] = f"/{resource}" + template_file_name = str(media_typey.expanduser().resolve()) + else: - resource = "/".join(relative_file_destination_path.parts) - if self._configuration.locales.multilingual: - resource_parts = resource.lstrip("/").split("/") - if resource_parts[0] in ( - x.alias for x in self._configuration.locales.values() - ): - resource = "/".join(resource_parts[1:]) - data["page_resource"] = f"/{resource}" - template = await self._environment.from_file(file_path) + template_file_name = None + template_code = self._environment.compile(content, filename=template_file_name) + template = self._environment.template_class.from_code( + self._environment, template_code, self._environment.globals + ) rendered = await template.render_async(data) - async with aiofiles.open(destination_file_path, "w", encoding="utf-8") as f: - await f.write(rendered) - await aiofiles_os.remove(file_path) - return destination_file_path + return rendered, to_media_type diff --git a/betty/media_type.py b/betty/media_type/__init__.py similarity index 82% rename from betty/media_type.py rename to betty/media_type/__init__.py index 4ac727fe4..2c8eb8753 100644 --- a/betty/media_type.py +++ b/betty/media_type/__init__.py @@ -23,6 +23,14 @@ class InvalidMediaType(ValueError): pass # pragma: no cover +class UnsupportedMediaType(ValueError): + """ + Raised when a media type is not supported. + """ + + pass # pragma: no cover + + @final class MediaType: """ @@ -33,8 +41,11 @@ class MediaType: _suffix: str | None - def __init__(self, media_type: str): + def __init__( + self, media_type: str, *, file_extensions: Sequence[str] | None = None + ): self._str = media_type + self._file_extensions = file_extensions or () message = EmailMessage() message["Content-Type"] = media_type type_part = message.get_content_type() @@ -107,6 +118,23 @@ def __eq__(self, other: Any) -> bool: other.parameters, ) + @property + def file_extensions(self) -> Sequence[str]: + """ + The file extensions associated with this media type. + """ + return list(self._file_extensions) + + @property + def preferred_file_extension(self) -> str | None: + """ + The preferred extension for files containing content of this media type. + """ + try: + return self.file_extensions[0] + except IndexError: + return None + @final class MediaTypeSchema(String): diff --git a/betty/media_type/media_types.py b/betty/media_type/media_types.py new file mode 100644 index 000000000..5e097726b --- /dev/null +++ b/betty/media_type/media_types.py @@ -0,0 +1,8 @@ +""" +Common media types. +""" + +from betty.media_type import MediaType + +HTML = MediaType("text/html", file_extensions=[".html"]) +JINJA2_HTML = MediaType("text/x.betty.jinja2-html", file_extensions=[".html.j2"]) diff --git a/betty/render.py b/betty/render.py index 45cb0f870..9c484b66a 100644 --- a/betty/render.py +++ b/betty/render.py @@ -5,54 +5,92 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import final, TYPE_CHECKING +from pathlib import Path +from typing import final, TYPE_CHECKING, Union, TypeAlias from typing_extensions import override +from betty.media_type import MediaType, UnsupportedMediaType +from betty.plugin import Plugin from betty.plugin.entry_point import EntryPointPluginRepository from betty.typing import internal if TYPE_CHECKING: - from betty.plugin import PluginRepository, Plugin + from betty.plugin import PluginRepository from betty.locale.localizer import Localizer from betty.job import Context - from pathlib import Path - from collections.abc import Sequence + from collections.abc import Sequence, Mapping + +MediaTypey: TypeAlias = Union[MediaType, Path] class Renderer(ABC): """ Render content to HTML. - Read more about :doc:`/development/plugin/renderer`. + See also :py:class:`betty.render.RendererPlugin`. """ + def to_media_type(self, media_typey: MediaTypey) -> MediaType | None: + """ + Get the media type the given media typey can be rendered to. + """ + if isinstance(media_typey, Path): + for from_media_type, to_media_type in self.media_types.items(): + for file_extension in from_media_type.file_extensions: + if media_typey.suffix == file_extension: + return to_media_type + else: + for from_media_type, to_media_type in self.media_types.items(): + if media_typey == from_media_type: + return to_media_type + return None + + def _assert_to_media_type(self, media_typey: MediaTypey) -> MediaType: + media_type = self.to_media_type(media_typey) + if media_type is None: + raise RuntimeError(f'{self} cannot render "{media_typey}"') + return media_type + @property @abstractmethod - def file_extensions(self) -> set[str]: + def media_types(self) -> Mapping[MediaType, MediaType]: """ - The extensions of the files this renderer can render. + The media types this renderer can render. + + :return: Keys are media types of content this renderer can render, and values are the media types of the + content after rendering. """ pass @abstractmethod - async def render_file( + async def render( self, - file_path: Path, + content: str, + media_typey: MediaTypey, *, job_context: Context | None = None, localizer: Localizer | None = None, - ) -> Path: + ) -> tuple[str, MediaType]: """ - Render a single file. + Render content. - :return: The file's new path, which may have been changed, e.g. a - renderer-specific extension may have been stripped from the end. + :return: The rendered content, and its new media type or path, which may have been changed. """ pass -RENDERER_REPOSITORY: PluginRepository[Renderer & Plugin] = EntryPointPluginRepository( +class RendererPlugin(Renderer, Plugin): + """ + A renderer as a plugin. + + Read more about :doc:`/development/plugin/renderer`. + """ + + pass + + +RENDERER_REPOSITORY: PluginRepository[RendererPlugin] = EntryPointPluginRepository( "betty.renderer" ) """ @@ -71,31 +109,29 @@ class SequentialRenderer(Renderer): def __init__(self, renderers: Sequence[Renderer]): self._renderers = renderers - self._file_extensions = { - file_extension + self._media_types = { + from_media_type: to_media_type for renderer in self._renderers - for file_extension in renderer.file_extensions + for from_media_type, to_media_type in renderer.media_types.items() } @override @property - def file_extensions(self) -> set[str]: - return self._file_extensions + def media_types(self) -> Mapping[MediaType, MediaType]: + return self._media_types @override - async def render_file( + async def render( self, - file_path: Path, + content: str, + media_typey: MediaTypey, *, job_context: Context | None = None, localizer: Localizer | None = None, - ) -> Path: + ) -> tuple[str, MediaType]: for renderer in self._renderers: - for renderer_file_extension in renderer.file_extensions: - if file_path.suffix.endswith(renderer_file_extension): - return await renderer.render_file( - file_path, - job_context=job_context, - localizer=localizer, - ) - return file_path + if renderer.to_media_type(media_typey): + return await renderer.render( + content, media_typey, job_context=job_context, localizer=localizer + ) + raise UnsupportedMediaType() diff --git a/documentation/development/plugin/renderer.rst b/documentation/development/plugin/renderer.rst index a0bf989e0..d83ae16f8 100644 --- a/documentation/development/plugin/renderer.rst +++ b/documentation/development/plugin/renderer.rst @@ -15,16 +15,15 @@ Renderers convert textual content to HTML. A renderer is often built to support Creating a renderer ------------------- -#. Create a new class that extends both :py:class:`betty.render.Renderer` and :py:class:`betty.plugin.Plugin` and implements the abstract methods, +#. Create a new class that extends :py:class:`betty.render.RendererPlugin` and implements the abstract methods, for example: .. code-block:: python from typing import override - from betty.plugin import Plugin - from betty.render import Renderer + from betty.render import RendererPlugin - class MyRenderer(Renderer, Plugin): + class MyRenderer(RendererPlugin): @override @classmethod def plugin_id(cls) -> str: