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