Source code for betty.project.extension.webpack

"""
Integrate Betty with `Webpack <https://webpack.js.org/>`_.

This module is internal.
"""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING, final, Self, ClassVar

from aiofiles.tempfile import TemporaryDirectory
from typing_extensions import override

from betty import fs
from betty._npm import NpmRequirement, NpmUnavailable
from betty.app import App
from betty.html import CssProvider
from betty.jinja2 import Jinja2Provider, Filters, ContextVars
from betty.job import Context
from betty.locale.localizable import _, Localizable, static
from betty.os import copy_tree
from betty.plugin import ShorthandPluginBase
from betty.project import Project, extension
from betty.project.extension import Extension
from betty.project.extension.webpack import build
from betty.project.extension.webpack.build import webpack_build_id, EntryPointProvider
from betty.project.extension.webpack.jinja2.filter import FILTERS
from betty.project.generate import GenerateSiteEvent
from betty.requirement import (
    Requirement,
    AllRequirements,
    AnyRequirement,
    RequirementError,
)
from betty.typing import internal, private

if TYPE_CHECKING:
    from betty.event_dispatcher import EventHandlerRegistry
    from collections.abc import Sequence


def _prebuilt_webpack_build_directory_path(
    entry_point_providers: Sequence[EntryPointProvider & Extension], debug: bool
) -> Path:
    return (
        fs.PREBUILT_ASSETS_DIRECTORY_PATH
        / "webpack"
        / f"build-{webpack_build_id(entry_point_providers, debug)}"
    )


async def _prebuild_webpack_assets() -> None:
    """
    Prebuild Webpack assets for inclusion in package builds.
    """
    async with App.new_temporary() as app, app:
        job_context = Context()
        async with Project.new_temporary(app) as project:
            project.configuration.extensions.enable(
                Webpack,
                *(
                    await extension.EXTENSION_REPOSITORY.select(
                        EntryPointProvider  # type: ignore[type-abstract]
                    )
                ),
            )
            async with project:
                extensions = await project.extensions
                webpack = extensions[Webpack]
                await webpack.prebuild(job_context=job_context)


[docs] class PrebuiltAssetsRequirement(Requirement): """ Check if prebuilt assets are available. """
[docs] @override def is_met(self) -> bool: return (fs.PREBUILT_ASSETS_DIRECTORY_PATH / "webpack").is_dir()
[docs] @override def summary(self) -> Localizable: return ( _("Pre-built Webpack front-end assets are available") if self.is_met() else _("Pre-built Webpack front-end assets are unavailable") )
async def _generate_assets(event: GenerateSiteEvent) -> None: project = event.project extensions = await project.extensions webpack = extensions[Webpack] build_directory_path = await webpack._generate_ensure_build_directory( job_context=event.job_context, ) event.job_context._webpack_build_directory_path = build_directory_path # type: ignore[attr-defined] await webpack._copy_build_directory( build_directory_path, project.configuration.www_directory_path )
[docs] @internal @final class Webpack(ShorthandPluginBase, Extension, CssProvider, Jinja2Provider): """ Integrate Betty with `Webpack <https://webpack.js.org/>`_. """ _plugin_id = "webpack" _plugin_label = static("Webpack") _requirement: ClassVar[Requirement | None] = None
[docs] @private def __init__(self, project: Project, public_css_paths: Sequence[str]): super().__init__(project) self._public_css_paths = public_css_paths
[docs] @override @classmethod async def new_for_project(cls, project: Project) -> Self: url_generator = await project.url_generator return cls(project, [url_generator.generate("betty-static:///css/vendor.css")])
[docs] @override def register_event_handlers(self, registry: EventHandlerRegistry) -> None: registry.add_handler(GenerateSiteEvent, _generate_assets)
[docs] @override @classmethod async def requirement(cls) -> Requirement: if cls._requirement is None: cls._requirement = AllRequirements( await super().requirement(), AnyRequirement(await NpmRequirement.new(), PrebuiltAssetsRequirement()), ) return cls._requirement
[docs] @override @classmethod def assets_directory_path(cls) -> Path: return Path(__file__).parent / "assets"
@override @property def public_css_paths(self) -> Sequence[str]: return self._public_css_paths
[docs] @override def new_context_vars(self) -> ContextVars: return { "webpack_js_entry_points": set(), }
@override @property def filters(self) -> Filters: return FILTERS async def _project_entry_point_providers( self, ) -> Sequence[EntryPointProvider & Extension]: extensions = await self._project.extensions return [ extension for extension in extensions.flatten() if isinstance(extension, EntryPointProvider) ]
[docs] async def prebuild(self, job_context: Context) -> None: """ Prebuild the Webpack assets. """ async with TemporaryDirectory() as working_directory_path_str: builder = await self._new_builder( Path(working_directory_path_str), job_context=job_context, ) build_directory_path = await builder.build() await self._copy_build_directory( build_directory_path, _prebuilt_webpack_build_directory_path( await self._project_entry_point_providers(), False ), )
async def _new_builder( self, working_directory_path: Path, *, job_context: Context, ) -> build.Builder: return build.Builder( working_directory_path, await self._project_entry_point_providers(), self._project.configuration.debug, await self._project.renderer, self._project.configuration.root_path, job_context=job_context, localizer=await self._project.app.localizer, ) async def _copy_build_directory( self, build_directory_path: Path, destination_directory_path: Path, ) -> None: await copy_tree(build_directory_path, destination_directory_path) async def _generate_ensure_build_directory( self, *, job_context: Context, ) -> Path: builder = await self._new_builder( self._project.app.binary_file_cache.with_scope("webpack").path, job_context=job_context, ) try: # (Re)build the assets if `npm` is available. return await builder.build() except NpmUnavailable: pass # Use prebuilt assets if they exist. prebuilt_webpack_build_directory_path = _prebuilt_webpack_build_directory_path( await self._project_entry_point_providers(), self._project.configuration.debug, ) if prebuilt_webpack_build_directory_path.exists(): return prebuilt_webpack_build_directory_path raise RequirementError(await self.requirement())