Source code for next.static.manager

"""Coordinate static backends, asset discovery, and placeholder injection.

The static manager loads backends lazily on first use, owns the shared
asset discovery instance, caches page-tree roots, and replaces every
registered placeholder token with the rendered tags once rendering
completes. It also injects the `next.min.js` wiring unless the
injection policy is `DISABLED`.

The module-level `default_manager` is a lazy handle around a single
static manager instance. Test code may replace the wrapped instance by
assigning to `default_manager._wrapped` without mucking with
module-level state. The settings-change hook in `next.conf` resets the
wrapper when `NEXT_FRAMEWORK` changes.
"""

from __future__ import annotations

import contextlib
import logging
from typing import TYPE_CHECKING, cast

from django.contrib.staticfiles.storage import staticfiles_storage
from django.utils.functional import LazyObject, empty

from next.conf import import_class_cached, next_framework_settings
from next.conf.signals import settings_reloaded
from next.pages.watch import get_pages_directories_for_watch

from .assets import default_kinds
from .backends import StaticBackend, StaticFilesBackend, StaticsFactory
from .collector import HEAD_CLOSE, StaticCollector, default_placeholders
from .discovery import AssetDiscovery, PathResolver
from .scripts import NEXT_JS_STATIC_PATH, NextScriptBuilder, ScriptInjectionPolicy
from .signals import collector_finalized, html_injected


if TYPE_CHECKING:
    from collections.abc import Callable
    from pathlib import Path

    from django.http import HttpRequest

    from next.components import ComponentInfo

    from .assets import StaticAsset
    from .collector import DedupStrategy, JsContextPolicy, PlaceholderSlot
    from .scripts import NextScriptBuilder as NextScriptBuilderType


logger = logging.getLogger(__name__)


_RUNTIME_SLOT_NAME = "scripts"


[docs] class StaticManager: """Coordinate static backends, asset discovery, and placeholder injection. Backends are loaded lazily from `NEXT_FRAMEWORK['DEFAULT_STATIC_BACKENDS']` on first access. URL resolution is handled by the built-in staticfiles backend by default, which delegates to Django staticfiles. """
[docs] def __init__(self) -> None: """Initialise empty backend and discovery caches, loaded lazily.""" self._backends: list[StaticBackend] = [] self._discovery: AssetDiscovery | None = None self._cached_page_roots: tuple[Path, ...] | None = None self._script_builder: NextScriptBuilderType | None = None self._dedup_factory: Callable[[], DedupStrategy] | None = None self._js_policy_factory: Callable[[], JsContextPolicy] | None = None
[docs] def __len__(self) -> int: """Return the number of configured backends, loading them if needed.""" self._ensure_backends() return len(self._backends)
@property def default_backend(self) -> StaticBackend: """Return the first configured backend used for file registration.""" self._ensure_backends() return self._backends[0] @property def discovery(self) -> AssetDiscovery: """Return the shared asset discovery instance.""" if self._discovery is None: self._discovery = AssetDiscovery( self, resolver=PathResolver(self.page_roots), ) return self._discovery
[docs] def discover_page_assets( self, file_path: Path, collector: StaticCollector, ) -> None: """Forward page asset discovery to the shared discovery instance.""" self._ensure_backends() self.discovery.discover_page_assets(file_path, collector)
[docs] def discover_component_assets( self, info: ComponentInfo, collector: StaticCollector, ) -> None: """Forward component asset discovery to the shared discovery instance.""" self._ensure_backends() # pragma: no cover self.discovery.discover_component_assets(info, collector) # pragma: no cover
[docs] def inject( self, html: str, collector: StaticCollector, *, page_path: Path | None = None, request: HttpRequest | None = None, ) -> str: """Replace every registered placeholder token with rendered tags. Each slot in `default_placeholders` contributes its bucket of collected assets. Asset rendering dispatches through the backend method named by `KindRegistry.renderer(asset.kind)`, so adding new kinds with new renderer methods does not require any changes here. The `scripts` slot also receives the next-dj runtime wiring when the injection policy is `AUTO`. A missing placeholder is left unchanged because `str.replace` returns the original string when there is nothing to replace. An empty collector yields empty tag sections. The preload hint is injected before `</head>` under the same policy. The optional `request` argument is forwarded to backend tag renderers and to the `collector_finalized` and `html_injected` signals. Backends may use it to rewrite asset URLs based on per-request state. The default backend ignores it. """ collector_finalized.send(sender=collector, page_path=page_path, request=request) html_before = html replaced: tuple[str, ...] | None = None if html_injected.receivers: replaced = tuple( slot.name for slot in default_placeholders if slot.token in html ) backend = self.default_backend for slot in default_placeholders: rendered = self._render_slot(slot, collector, backend, request=request) html = html.replace(slot.token, rendered) html = self._inject_preload_hint(html) if replaced is not None: html_injected.send( sender=self, html_before=html_before, html_after=html, collector=collector, placeholders_replaced=replaced, injected_bytes=len(html) - len(html_before), request=request, ) return html
def _next_script_builder(self) -> NextScriptBuilderType: if self._script_builder is None: url = str(staticfiles_storage.url(NEXT_JS_STATIC_PATH)) options = next_framework_settings.NEXT_JS_OPTIONS if not isinstance(options, dict): # pragma: no cover options = {} self._script_builder = NextScriptBuilder.from_options(url, options) return self._script_builder def _render_slot( self, slot: PlaceholderSlot, collector: StaticCollector, backend: StaticBackend, *, request: HttpRequest | None, ) -> str: user_tags = self._render_tags( collector.assets_in_slot(slot.name), backend, request=request ) if slot.name == _RUNTIME_SLOT_NAME: return self._wrap_with_runtime(user_tags, collector) return user_tags def _wrap_with_runtime(self, user_tags: str, collector: StaticCollector) -> str: builder = self._next_script_builder() if builder.policy is ScriptInjectionPolicy.AUTO: init_payload = builder.init_script( collector.js_context(), key_serializers=collector.js_context_serializers(), ) next_scripts = f"{builder.script_tag()}\n{init_payload}\n" return next_scripts + user_tags if user_tags else next_scripts return user_tags def _inject_preload_hint(self, html: str) -> str: builder = self._next_script_builder() if builder.policy is not ScriptInjectionPolicy.AUTO: return html replacement = f"{builder.preload_link()}\n{HEAD_CLOSE}" return html.replace(HEAD_CLOSE, replacement, 1) def _render_tags( self, assets: list[StaticAsset], backend: StaticBackend, *, request: HttpRequest | None, ) -> str: return "\n".join(self._render_one(asset, backend, request) for asset in assets) def _render_one( self, asset: StaticAsset, backend: StaticBackend, request: HttpRequest | None, ) -> str: if asset.inline is not None: return asset.inline renderer_name = default_kinds.renderer(asset.kind) renderer = getattr(backend, renderer_name) return cast("str", renderer(asset.url, request=request)) def _ensure_backends(self) -> None: if not self._backends: self._reload_config() def _reload_config(self) -> None: """Rebuild the backend list from merged framework settings. Only `ImportError`, `TypeError`, and `ValueError` from a single backend entry are swallowed. Other exceptions propagate so bugs in user backends surface loudly. """ self._backends.clear() self._discovery = None self._cached_page_roots = None self._script_builder = None self._dedup_factory = None self._js_policy_factory = None configs = next_framework_settings.DEFAULT_STATIC_BACKENDS if not isinstance(configs, list): # pragma: no cover configs = [] for config in configs: if not isinstance(config, dict): # pragma: no cover continue try: backend = StaticsFactory.create_backend(config) except (ImportError, TypeError, ValueError): logger.exception("Error creating static backend from config %s", config) continue self._backends.append(backend) if not self._backends: self._backends.append(StaticFilesBackend()) self._resolve_collector_strategies() def _resolve_collector_strategies(self) -> None: """Read dedup and js-context policy dotted paths from the first backend.""" options = dict(self.default_backend.config.get("OPTIONS") or {}) dedup_path = options.get("DEDUP_STRATEGY") policy_path = options.get("JS_CONTEXT_POLICY") self._dedup_factory = ( cast("Callable[[], DedupStrategy]", import_class_cached(str(dedup_path))) if dedup_path else None ) self._js_policy_factory = ( cast("Callable[[], JsContextPolicy]", import_class_cached(str(policy_path))) if policy_path else None )
[docs] def create_collector(self) -> StaticCollector: """Build a new `StaticCollector` wired with configured strategies.""" self._ensure_backends() dedup = self._dedup_factory() if self._dedup_factory is not None else None policy = ( self._js_policy_factory() if self._js_policy_factory is not None else None ) return StaticCollector(dedup=dedup, js_context_policy=policy)
[docs] def page_roots(self) -> tuple[Path, ...]: """Return absolute page-tree roots from configured page backends.""" if self._cached_page_roots is not None: return self._cached_page_roots roots: list[Path] = [] for root in get_pages_directories_for_watch(): with contextlib.suppress(OSError): roots.append(root.resolve()) self._cached_page_roots = tuple(roots) return self._cached_page_roots
[docs] class DefaultStaticManager(LazyObject): """Lazy handle that defers the construction of a static manager. The wrapped manager is built on first access. Tests may replace it by assigning to `_wrapped` directly. The settings-change hook in `next.conf` resets the wrapper when `NEXT_FRAMEWORK` changes. """ def _setup(self) -> None: self._wrapped = StaticManager()
default_manager: DefaultStaticManager = DefaultStaticManager()
[docs] def reset_default_manager() -> None: """Drop the wrapped static manager so the next access rebuilds it. Hooked into the `settings_reloaded` signal from `next.conf` so that test code changing `NEXT_FRAMEWORK` via `override_settings` sees a fresh manager on the next access. """ default_manager._wrapped = empty # type: ignore[assignment]
def _on_settings_reloaded(**_kwargs: object) -> None: """Reset the default static manager when framework settings reload.""" reset_default_manager() settings_reloaded.connect(_on_settings_reloaded)