Source code for next.static.discovery

"""Discover co-located CSS/JS files and page and component module asset lists.

This module owns the filesystem side of the static pipeline. It walks
layout chains, reads `styles` and `scripts` module lists, and pushes
results onto a collector via the active backend.

The path-to-logical-name conversion lives on the `PathResolver` so both
discovery and the staticfiles finder share the exact same mapping. The
`StemRegistry` controls which filenames are auto-picked-up per role. It
lets users teach the framework about new stems like `page.css` or
`panel.js` without patching the core.

The `BackendProvider` protocol inverts the dependency direction. The
discovery layer does not import the static manager directly. Any object
exposing `default_backend` and `page_roots` satisfies the protocol,
which makes unit-testing without a full manager trivial.

The provider contract requires that `page_roots` returns already
resolved absolute paths. Both the static manager and the co-located
finder satisfy this contract, which lets the discovery layer skip a
round of resolution on every logical-name lookup.
"""

from __future__ import annotations

import logging
import os
from collections import OrderedDict
from typing import TYPE_CHECKING, Protocol, runtime_checkable

from next.pages import loaders as pages_loaders

from .assets import StaticAsset, default_kinds
from .collector import default_placeholders
from .signals import asset_registered


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

    from next.components import ComponentInfo

    from .backends import StaticBackend
    from .collector import StaticCollector


logger = logging.getLogger(__name__)


_MODULE_LIST_CACHE_MAX_SIZE = 2048
_LAYOUT_DIR_CACHE_MAX_SIZE = 2048


def _url_suffix(url: str) -> str:
    """Return the lowercase dot-suffix of a URL or empty string when absent."""
    last_segment = url.rsplit("?", 1)[0].rsplit("#", 1)[0].rsplit("/", 1)[-1]
    dot = last_segment.rfind(".")
    if dot < 0:
        return ""
    return last_segment[dot:].lower()


def _rel_path_str(child: Path, root: Path) -> str | None:
    """Return the forward-slashed path of `child` relative to `root`.

    Both operands must already be resolved absolute paths. Returns an
    empty string when `child == root`, and `None` when `child` is not
    nested under `root`. Skips the per-segment work of
    `Path.relative_to().parts`.
    """
    child_str = os.fspath(child)
    root_str = os.fspath(root)
    if child_str == root_str:
        return ""
    prefix = root_str + os.sep
    if not child_str.startswith(prefix):  # pragma: no cover
        return None
    rel = child_str[len(prefix) :]
    if os.sep != "/":  # pragma: no cover
        rel = rel.replace(os.sep, "/")
    return rel


[docs] @runtime_checkable class BackendProvider(Protocol): """Contract consumed by the asset discovery layer. The static manager is the canonical implementation. Tests can pass any object exposing `default_backend` and `page_roots` without instantiating the full manager. Implementations must return resolved absolute paths from `page_roots`. """ @property def default_backend(self) -> StaticBackend: """Return the primary backend used for file registration.""" raise NotImplementedError
[docs] def page_roots(self) -> tuple[Path, ...]: """Return the configured page-tree roots as resolved absolute paths.""" raise NotImplementedError
[docs] class StemRegistry: """Map discovery role to registered filename stems. Default roles and their stems are as follows. The `template` role maps to `["template"]` and matches `template.css` or `template.js`. The `layout` role maps to `["layout"]` and matches `layout.css` or `layout.js`. The `component` role maps to `["component"]` and matches `component.css`. Users may register extra stems during `AppConfig.ready` to teach discovery about new filenames. Example:: default_stems.register("template", "page") The example above teaches discovery to also pick up `page.css` or `page.js` alongside `template.css` or `template.js`. """ DEFAULT_ROLES: tuple[str, ...] = ("template", "layout", "component")
[docs] def __init__(self) -> None: """Seed the registry with the built-in template, layout, and component roles.""" self._roles: dict[str, list[str]] = { "template": ["template"], "layout": ["layout"], "component": ["component"], }
[docs] def register(self, role: str, stem: str) -> None: """Add a stem under the given role, creating the role when missing.""" stems = self._roles.setdefault(role, []) if stem not in stems: stems.append(stem)
[docs] def stems(self, role: str) -> tuple[str, ...]: """Return registered stems for the role in registration order.""" return tuple(self._roles.get(role, ()))
[docs] def roles(self) -> tuple[str, ...]: """Return all registered roles in registration order.""" return tuple(self._roles)
default_stems: StemRegistry = StemRegistry()
[docs] class PathResolver: """Resolve page root and logical names for page, layout, and component paths. The resolver is shared between the asset discovery layer and the staticfiles finder so both layers produce identical logical names for the same on-disk location. The resolver assumes that the provider callable returns already resolved absolute page roots. """
[docs] def __init__( self, page_roots_provider: Callable[[], tuple[Path, ...]], ) -> None: """Store the page-roots provider callable consulted on every lookup.""" self._provider = page_roots_provider self._find_page_root_cache: dict[Path, Path | None] = {}
[docs] def page_roots(self) -> tuple[Path, ...]: """Return the current tuple of page tree roots from the provider.""" return self._provider()
[docs] def find_page_root(self, path: Path) -> Path | None: """Return the page tree root that contains the path, or None.""" cached = self._find_page_root_cache.get(path) if cached is not None or path in self._find_page_root_cache: return cached resolved_parent = path.parent.resolve() for root in self.page_roots(): if resolved_parent.is_relative_to(root): self._find_page_root_cache[path] = root return root self._find_page_root_cache[path] = None return None
[docs] def logical_name_for_template( self, template_dir: Path, page_root: Path | None, ) -> str: """Return the logical URL name for a page template directory. The caller is expected to pass a resolved `template_dir` and a resolved `page_root` from `find_page_root`. """ if page_root is None: return self._fallback(template_dir) rel = _rel_path_str(template_dir, page_root) if rel is None: # pragma: no cover return self._fallback(template_dir) return rel or "index"
[docs] def logical_name_for_layout( self, layout_dir: Path, page_root: Path | None, ) -> str: """Return the logical URL name for a layout directory. The caller is expected to pass a resolved `layout_dir` and a resolved `page_root` from `find_page_root`. """ if page_root is None: return f"{self._fallback(layout_dir)}/layout" rel = _rel_path_str(layout_dir, page_root) if rel is None: # pragma: no cover return f"{self._fallback(layout_dir)}/layout" return f"{rel}/layout" if rel else "layout"
@staticmethod def _fallback(directory: Path) -> str: return directory.name or "index"
[docs] class AssetDiscovery: """Detect co-located asset files and module-level asset list variables. The `provider` argument supplies the active backend and the page tree roots. The optional `resolver` argument is a path resolver. The default resolver is backed by the provider. The optional `stems` argument is a stem registry. The default is the process-wide `default_stems`. """
[docs] def __init__( self, provider: BackendProvider, *, resolver: PathResolver | None = None, stems: StemRegistry | None = None, ) -> None: """Bind the provider and wire optional resolver and stems.""" self._provider = provider self._resolver = resolver or PathResolver(provider.page_roots) self._stems = stems or default_stems self._module_list_cache: OrderedDict[Path, dict[str, list[str]]] = OrderedDict() self._layout_dir_cache: OrderedDict[Path, list[Path]] = OrderedDict()
[docs] def discover_page_assets( self, file_path: Path, collector: StaticCollector, ) -> None: """Collect layout, template, and module-level assets for a page file. Assets are added from the outermost layout inward, then from the template directory, then from `styles` and `scripts` module lists declared in `page.py`. """ resolved = file_path.resolve() page_root = self._resolver.find_page_root(resolved) for layout_dir in self._find_layout_directories(resolved, page_root): self._collect_role_directory( layout_dir, logical_name=self._resolver.logical_name_for_layout( layout_dir, page_root ), role="layout", collector=collector, ) self._collect_role_directory( resolved.parent, logical_name=self._resolver.logical_name_for_template( resolved.parent, page_root ), role="template", collector=collector, ) if resolved.exists(): self._collect_module_lists(resolved, collector)
[docs] def discover_component_assets( self, info: ComponentInfo, collector: StaticCollector, ) -> None: """Collect co-located CSS, JS, and module asset lists for a component.""" component_dir = self._component_directory(info) if component_dir is None: return logical_name = f"components/{info.name}" self._collect_role_directory( component_dir, logical_name=logical_name, role="component", collector=collector, ) module_path = info.module_path if module_path is not None and module_path.exists(): self._collect_module_lists(module_path, collector)
def _collect_role_directory( self, directory: Path, *, logical_name: str, role: str, collector: StaticCollector, ) -> None: """Register `{stem}{ext}` files for every registered kind. The set of extensions probed comes from `KindRegistry.kinds()`, so registering a new kind during `AppConfig.ready` is enough to teach discovery about additional file types. """ for stem in self._stems.stems(role): for kind in default_kinds.kinds(): suffix = default_kinds.extension(kind) candidate = directory / f"{stem}{suffix}" if candidate.exists(): self._register_file(candidate, logical_name, kind, collector) def _collect_module_lists( self, module_path: Path, collector: StaticCollector, ) -> None: """Read URL list variables matching every registered placeholder slot. The discovery layer iterates over registered slots in `default_placeholders` and reads the variable named after each slot. Each URL gets a `kind` derived from its file extension via `KindRegistry.kind_for_extension`. URLs whose suffix is not in the registry are dropped with a debug log. The caller in `discover_page_assets` passes a resolved module path. The component entry point still calls with the raw path, so the key is normalised here as a safety net. """ cache_key = module_path if module_path.is_absolute() else module_path.resolve() if cache_key in self._module_list_cache: self._module_list_cache.move_to_end(cache_key) cached = self._module_list_cache[cache_key] else: module = pages_loaders._load_python_module(module_path) if module is None: self._module_list_cache[cache_key] = {} if len(self._module_list_cache) > _MODULE_LIST_CACHE_MAX_SIZE: self._module_list_cache.popitem(last=False) return cached = { slot.name: pages_loaders._read_string_list(module, slot.name) for slot in default_placeholders } self._module_list_cache[cache_key] = cached if len(self._module_list_cache) > _MODULE_LIST_CACHE_MAX_SIZE: self._module_list_cache.popitem(last=False) for slot_name, urls in cached.items(): for url in urls: self._register_module_url(url, slot_name, collector) def _register_module_url( self, url: str, slot_name: str, collector: StaticCollector, ) -> None: """Resolve a module-level URL to a kind and add it to the collector. The kind comes from `KindRegistry.kind_for_extension(suffix)` where suffix is the lowercase trailing dot-extension of the URL. URLs whose extension is not registered, or whose resolved kind belongs to a different slot, are dropped with a debug log. """ suffix = _url_suffix(url) if not suffix: logger.debug("Module URL %r has no recognised extension", url) return kind = default_kinds.kind_for_extension(suffix) if kind is None: logger.debug("Module URL %r has unregistered extension %r", url, suffix) return if default_kinds.slot(kind) != slot_name: logger.debug( "Module URL %r maps to kind %r but list %r expects slot %r", url, kind, slot_name, slot_name, ) return collector.add(StaticAsset(url=url, kind=kind)) def _register_file( self, source_path: Path, logical_name: str, kind: str, collector: StaticCollector, ) -> None: """Register a file with the backend and add the result to the collector. Warnings are logged for `OSError` and `ValueError`. All other exception types propagate so bugs in custom backends surface loudly. """ backend = self._provider.default_backend try: url = backend.register_file(source_path, logical_name, kind) except (OSError, ValueError) as e: logger.warning( "Failed to register static asset %s as %r: %s", source_path, logical_name, e, extra={"source_path": str(source_path), "kind": kind}, ) return asset = StaticAsset(url=url, kind=kind, source_path=source_path.resolve()) collector.add(asset) asset_registered.send( sender=asset, collector=collector, backend=backend, ) def _component_directory(self, info: ComponentInfo) -> Path | None: """Return the directory that holds a composite component, or None.""" if info.is_simple: return None if info.template_path is not None: return info.template_path.parent if info.module_path is not None: # pragma: no cover return info.module_path.parent return None # pragma: no cover def _find_layout_directories( self, file_path: Path, page_root: Path | None, ) -> list[Path]: """Walk up from the page directory and return layout dirs outermost first. The caller is expected to pass a resolved absolute `file_path`. The resolver contract also guarantees that `page_root` is resolved, which lets this loop compare paths with `==` without issuing another filesystem call per iteration. """ if file_path in self._layout_dir_cache: self._layout_dir_cache.move_to_end(file_path) return self._layout_dir_cache[file_path] directories: list[Path] = [] current_dir = file_path.parent while True: if (current_dir / "layout.djx").exists(): directories.append(current_dir) if page_root is not None and current_dir == page_root: break parent = current_dir.parent if parent == current_dir: break current_dir = parent result = list(reversed(directories)) self._layout_dir_cache[file_path] = result if len(self._layout_dir_cache) > _LAYOUT_DIR_CACHE_MAX_SIZE: self._layout_dir_cache.popitem(last=False) return result