v0.166.0
  1import os
  2
  3from plain.runtime import settings
  4from plain.urls import URLPattern, reverse
  5from plain.urls import path as url_path
  6
  7from .exceptions import PageNotFoundError
  8from .pages import Page
  9
 10
 11class PagesRegistry:
 12    """
 13    The registry loads up all the pages at once, so we only have to do a
 14    dict key lookup at runtime to get a page.
 15    """
 16
 17    def __init__(self):
 18        self._url_name_mappings = {}  # url_name -> relative_path
 19        self._path_mappings = {}  # relative_path -> absolute_path
 20        self._companions = {}  # html_relative_path -> md_relative_path
 21
 22    def get_page_urls(self) -> tuple[URLPattern, ...]:
 23        """
 24        Generate a list of real urls based on the files that exist.
 25        This way, you get a concrete url reversingerror if you try
 26        to refer to a page/url that isn't going to work.
 27        """
 28        companion_md_paths = set(self._companions.values())
 29        paths = []
 30
 31        for relative_path in self._path_mappings:
 32            if relative_path in companion_md_paths:
 33                continue
 34
 35            page = self.get_page_from_path(relative_path)
 36            paths.extend(page.get_urls())
 37
 38        # Add .md URLs for companion markdown files
 39        if settings.PAGES_SERVE_MARKDOWN and self._companions:
 40            from .views import PageMarkdownView  # circular
 41
 42            for html_path, md_path in self._companions.items():
 43                html_page = self.get_page_from_path(html_path)
 44                url_name = html_page.get_url_name()
 45                if url_name:
 46                    md_url_name = f"{url_name}-md"
 47                    self._url_name_mappings[md_url_name] = md_path
 48                    paths.append(
 49                        url_path(
 50                            md_path,
 51                            PageMarkdownView,
 52                            name=md_url_name,
 53                        )
 54                    )
 55
 56        return tuple(paths)
 57
 58    def discover_pages(self, pages_dir: str) -> None:
 59        # Collect all files first so we can detect .md/.html pairs
 60        candidates = {}
 61        for root, _, files in os.walk(pages_dir, followlinks=True):
 62            for file in files:
 63                relative_path = str(
 64                    os.path.relpath(os.path.join(root, file), pages_dir)
 65                )
 66                absolute_path = str(os.path.join(root, file))
 67                candidates[relative_path] = absolute_path
 68
 69        # First pass: register all non-.md files so _path_mappings
 70        # reflects which HTML pages are actually routable
 71        for relative_path, absolute_path in candidates.items():
 72            if os.path.splitext(relative_path)[1] != ".md":
 73                self._register_page(relative_path, absolute_path)
 74
 75        # Second pass: register .md files, detecting companions
 76        # against _path_mappings (not candidates) to skip non-routable
 77        # HTML like .template.html files
 78        for relative_path, absolute_path in candidates.items():
 79            if os.path.splitext(relative_path)[1] != ".md":
 80                continue
 81
 82            stem = os.path.splitext(relative_path)[0]
 83            html_path = stem + ".html"
 84
 85            if html_path in self._path_mappings:
 86                # Companion .md — paired with a routable HTML page
 87                self._path_mappings[relative_path] = absolute_path
 88                self._companions[html_path] = relative_path
 89            else:
 90                self._register_page(relative_path, absolute_path)
 91
 92    def _register_page(self, relative_path: str, absolute_path: str) -> None:
 93        """Register a single page in the registry."""
 94        page = Page(relative_path=relative_path, absolute_path=absolute_path)
 95        urls = page.get_urls()
 96
 97        # Some pages don't get any urls (like templates)
 98        if not urls:
 99            return
100
101        self._path_mappings[relative_path] = absolute_path
102
103        for url_path_obj in urls:
104            url_name = url_path_obj.name
105            self._url_name_mappings[url_name] = relative_path
106
107    def get_page_from_name(self, url_name: str) -> Page:
108        """Get a page by its URL name."""
109        try:
110            relative_path = self._url_name_mappings[url_name]
111            return self.get_page_from_path(relative_path)
112        except KeyError:
113            raise PageNotFoundError(f"Could not find a page for URL name {url_name}")
114
115    def get_page_from_path(self, relative_path: str) -> Page:
116        """Get a page by its relative file path."""
117        try:
118            absolute_path = self._path_mappings[relative_path]
119            # Instantiate the page here, so we don't store a ton of cached data over time
120            # as we render all the pages
121            return Page(relative_path=relative_path, absolute_path=absolute_path)
122        except KeyError:
123            raise PageNotFoundError(f"Could not find a page for path {relative_path}")
124
125    def get_markdown_companion(self, url_name: str) -> Page | None:
126        """Look up the paired markdown Page for a given url_name."""
127        try:
128            html_path = self._url_name_mappings[url_name]
129        except KeyError:
130            return None
131        md_path = self._companions.get(html_path)
132        if md_path:
133            return self.get_page_from_path(md_path)
134        return None
135
136    def get_markdown_url(self, url_name: str) -> str | None:
137        """Get the markdown URL for a page, whether standalone .md or paired with .html."""
138        md_url_name = f"{url_name}-md"
139        if md_url_name in self._url_name_mappings:
140            return reverse(f"pages:{md_url_name}")
141        return None
142
143
144pages_registry = PagesRegistry()