Source code for next.pages.loaders

"""Template-text loaders and the layout composition engine.

`TemplateLoader` is the abstract contract. The page manager consults
`module.template` directly and then iterates the loader chain built
from `NEXT_FRAMEWORK["TEMPLATE_LOADERS"]`. The default chain contains
only `DjxTemplateLoader`. `PythonTemplateLoader` is available for
projects that register it explicitly. Registering it changes nothing
at render time and only affects how the `next.W043` conflict check
reports the body source. The manager does not call it by default.

`DjxTemplateLoader` reads a sibling `template.djx`.
`LayoutTemplateLoader` composes outer `layout.djx` wrappers up the
directory chain. It is not registered through `TEMPLATE_LOADERS`.
Layouts have their own dedicated path.
"""

from __future__ import annotations

import contextlib
import importlib.util
import logging
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, ClassVar

from next.conf import next_framework_settings
from next.conf.imports import import_class_cached
from next.conf.signals import settings_reloaded
from next.utils import classify_dirs_entries, resolve_base_dir


if TYPE_CHECKING:
    import types
    from pathlib import Path


logger = logging.getLogger(__name__)


_MAX_ANCESTOR_WALK_DEPTH = 64


def _load_python_module(file_path: Path) -> types.ModuleType | None:
    """Load `file_path` as a module or return `None` on failure."""
    try:
        spec = importlib.util.spec_from_file_location("page_module", file_path)
        if not spec or not spec.loader:
            return None
        module = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(module)
    except (ImportError, AttributeError, OSError, SyntaxError) as e:
        logger.debug("Could not load module %s: %s", file_path, e)
        return None
    else:
        return module


_MODULE_MEMO: dict[Path, tuple[float, types.ModuleType | None]] = {}


def _load_python_module_memo(file_path: Path) -> types.ModuleType | None:
    """Return `_load_python_module(file_path)` memoised by mtime.

    Different call sites (`PythonTemplateLoader.can_load`, `load_template`,
    and `Page._create_regular_page_pattern`) previously executed the
    module up to three times per URL dispatch. The memo keys by mtime so
    that autoreload and template-stale detection still pick up edits.
    """
    try:
        mtime = file_path.stat().st_mtime
    except OSError:
        _MODULE_MEMO.pop(file_path, None)
        return _load_python_module(file_path)

    cached = _MODULE_MEMO.get(file_path)
    if cached is not None and cached[0] == mtime:
        return cached[1]

    module = _load_python_module(file_path)
    _MODULE_MEMO[file_path] = (mtime, module)
    return module


_ADDITIONAL_LAYOUTS_CACHE: list[Path] | None = None


def _reset_additional_layouts_cache(**_kwargs: object) -> None:
    """Drop cached root-level `layout.djx` paths on settings reload."""
    global _ADDITIONAL_LAYOUTS_CACHE  # noqa: PLW0603
    _ADDITIONAL_LAYOUTS_CACHE = None


settings_reloaded.connect(_reset_additional_layouts_cache)


def _read_string_list(module: types.ModuleType, attr: str) -> list[str]:
    """Return a module-level string-sequence attribute or an empty list."""
    value = getattr(module, attr, None)
    if not isinstance(value, (list, tuple)):
        return []
    return [str(item) for item in value if isinstance(item, str) and item]


[docs] class TemplateLoader(ABC): """Pluggable source of template text for a `page.py` path. Subclasses set `source_name` to the filename they back. Typical values are `"template.djx"` or `"template.md"`. The name is surfaced in the `next.W043` body-source conflict check. """ source_name: ClassVar[str] = ""
[docs] @abstractmethod def can_load(self, file_path: Path) -> bool: """Return whether this loader applies without heavy work."""
[docs] @abstractmethod def load_template(self, file_path: Path) -> str | None: """Return the template source. Return `None` when unavailable."""
[docs] def source_path(self, file_path: Path) -> Path | None: """Return the filesystem path this loader reads for `file_path`. The page manager uses the result to snapshot file mtimes for stale-cache detection. The default returns `None` for non-file-based loaders. Subclasses override when they back a sibling file. """ _ = file_path return None
[docs] class PythonTemplateLoader(TemplateLoader): """Load from `page.py` when the module defines a `template` attribute.""" source_name: ClassVar[str] = "template"
[docs] def can_load(self, file_path: Path) -> bool: """Return whether the module loads and defines `template`.""" module = _load_python_module_memo(file_path) return module is not None and hasattr(module, "template")
[docs] def load_template(self, file_path: Path) -> str | None: """Return `module.template` if the module exposes it.""" module = _load_python_module_memo(file_path) return getattr(module, "template", None) if module else None
[docs] class DjxTemplateLoader(TemplateLoader): """Load from a sibling `template.djx` next to `page.py`.""" source_name: ClassVar[str] = "template.djx"
[docs] def can_load(self, file_path: Path) -> bool: """Return whether sibling `template.djx` exists.""" return (file_path.parent / "template.djx").exists()
[docs] def load_template(self, file_path: Path) -> str | None: """Return the file contents of `template.djx`.""" djx_file = file_path.parent / "template.djx" try: return djx_file.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): return None
[docs] def source_path(self, file_path: Path) -> Path | None: """Return the sibling `template.djx` path for stale-cache detection.""" djx_file = file_path.parent / "template.djx" return djx_file if djx_file.exists() else None
[docs] class LayoutTemplateLoader(TemplateLoader): """Compose nested `layout.djx` wrappers around the page template."""
[docs] def can_load(self, file_path: Path) -> bool: """Return whether at least one `layout.djx` exists on the path.""" return self._find_layout_files(file_path) is not None
[docs] def load_template(self, file_path: Path) -> str | None: """Return the composed template with the page inside the innermost slot.""" layout_files = self._find_layout_files(file_path) if not layout_files: return None template_content = self._wrap_in_template_block(file_path) return self._compose_layout_hierarchy(template_content, layout_files)
[docs] def compose_body(self, body: str, file_path: Path) -> str: """Wrap `body` through the ancestor layout chain for `file_path`. Returns `body` verbatim when no layouts apply. When a sibling `layout.djx` exists the innermost layout owns the `{% block template %}` slot, so `body` is substituted as-is. Otherwise `body` is wrapped in a `{% block template %}` block before substitution so the ancestor layout's placeholder remains a valid block. """ layout_files = self._find_layout_files(file_path) if not layout_files: return body sibling_layout = (file_path.parent / "layout.djx").exists() wrapped = ( body if sibling_layout else f"{{% block template %}}{body}{{% endblock template %}}" ) return self._compose_layout_hierarchy(wrapped, layout_files)
def _find_layout_files(self, file_path: Path) -> list[Path] | None: """Return `layout.djx` paths from near to far plus global layouts.""" layout_files = [] current_dir = file_path.parent for _ in range(_MAX_ANCESTOR_WALK_DEPTH): if current_dir == current_dir.parent: break layout_file = current_dir / "layout.djx" if layout_file.exists(): layout_files.append(layout_file) current_dir = current_dir.parent if additional_layouts := self._get_additional_layout_files(): for additional_layout in additional_layouts: if additional_layout not in layout_files: layout_files.append(additional_layout) return layout_files or None def _get_additional_layout_files(self) -> list[Path]: """Return root-level `layout.djx` files from each page backend `DIRS`.""" global _ADDITIONAL_LAYOUTS_CACHE # noqa: PLW0603 if _ADDITIONAL_LAYOUTS_CACHE is not None: return _ADDITIONAL_LAYOUTS_CACHE configs = next_framework_settings.DEFAULT_PAGE_BACKENDS or [] if not isinstance(configs, list): configs = [] candidates = ( layout for c in configs if isinstance(c, dict) for d in self._get_pages_dirs_for_config(c) if d.exists() and (layout := d / "layout.djx").exists() ) result = list(dict.fromkeys(candidates)) _ADDITIONAL_LAYOUTS_CACHE = result return result def _get_pages_dirs_for_config(self, config: dict) -> list[Path]: """Return candidate roots from one router `DIRS` entry (paths only).""" path_roots, _ = classify_dirs_entries( list(config.get("DIRS") or []), resolve_base_dir(), ) return list(path_roots) def _wrap_in_template_block(self, file_path: Path) -> str: """Return the page body wrapped in `{% block template %}` when needed.""" template_file = file_path.parent / "template.djx" if template_file.exists(): with contextlib.suppress(OSError, UnicodeDecodeError): content = template_file.read_text(encoding="utf-8") layout_file = file_path.parent / "layout.djx" if layout_file.exists(): return content return f"{{% block template %}}{content}{{% endblock template %}}" return "{% block template %}{% endblock template %}" def _compose_layout_hierarchy( self, template_content: str, layout_files: list[Path], ) -> str: """Return layouts wrapped outermost last, with the page in the first slot.""" result = template_content for layout_file in layout_files: with contextlib.suppress(OSError, UnicodeDecodeError): layout_content = layout_file.read_text(encoding="utf-8") for placeholder in ( "{% block template %}{% endblock template %}", "{% block template %}{% endblock %}", ): if placeholder in layout_content: result = layout_content.replace(placeholder, result, 1) break return result
[docs] class LayoutManager: """Cache composed layout strings per page path."""
[docs] def __init__(self) -> None: """Initialise an empty layout cache.""" self._layout_registry: dict[Path, str] = {} self._layout_loader = LayoutTemplateLoader()
[docs] def discover_layouts_for_template(self, template_path: Path) -> str | None: """Compose and store layout text when `LayoutTemplateLoader` applies.""" if not self._layout_loader.can_load(template_path): return None composed_template = self._layout_loader.load_template(template_path) if composed_template: self._layout_registry[template_path] = composed_template return composed_template
[docs] def get_layout_template(self, template_path: Path) -> str | None: """Return the cached composed template for `template_path`.""" return self._layout_registry.get(template_path)
[docs] def clear_registry(self) -> None: """Drop all cached layout strings.""" self._layout_registry.clear()
_REGISTERED_LOADERS_CACHE: list[TemplateLoader] | None = None def build_registered_loaders() -> list[TemplateLoader]: """Instantiate `TEMPLATE_LOADERS` dotted paths into `TemplateLoader` instances. Entries that cannot be imported or are not `TemplateLoader` subclasses are skipped with a debug-level log. `check_template_loaders` is the user-visible report for the same misconfigurations. The result is memoised and reset on `settings_reloaded`. """ global _REGISTERED_LOADERS_CACHE # noqa: PLW0603 if _REGISTERED_LOADERS_CACHE is not None: return _REGISTERED_LOADERS_CACHE configured = next_framework_settings.TEMPLATE_LOADERS seen: set[type[TemplateLoader]] = set() instances: list[TemplateLoader] = [] for entry in configured: if not isinstance(entry, str): logger.debug("Skipping non-string TEMPLATE_LOADERS entry: %r", entry) continue try: cls = import_class_cached(entry) except ImportError as e: logger.debug("Cannot import TEMPLATE_LOADERS entry %r: %s", entry, e) continue if not isinstance(cls, type) or not issubclass(cls, TemplateLoader): logger.debug( "TEMPLATE_LOADERS entry %r is not a TemplateLoader subclass", entry, ) continue if cls in seen: logger.debug("Skipping duplicate TEMPLATE_LOADERS entry: %r", entry) continue seen.add(cls) instances.append(cls()) _REGISTERED_LOADERS_CACHE = instances return _REGISTERED_LOADERS_CACHE def _reset_registered_loaders_cache(**_kwargs: object) -> None: """Drop cached loader instances on settings reload.""" global _REGISTERED_LOADERS_CACHE # noqa: PLW0603 _REGISTERED_LOADERS_CACHE = None settings_reloaded.connect(_reset_registered_loaders_cache)