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()