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