1import os
2from functools import cached_property
3from typing import Any
4
5import frontmatter
6from plain.runtime import settings
7from plain.templates import Template
8from plain.urls import URLPattern, path
9
10from .markdown import render_markdown
11
12__all__ = ["Page"]
13
14
15class PageRenderError(Exception):
16 pass
17
18
19class Page:
20 def __init__(self, relative_path: str, absolute_path: str):
21 self.relative_path = relative_path
22 self.absolute_path = absolute_path
23 self._template_context: dict[str, Any] = {}
24 self._extension = os.path.splitext(absolute_path)[1]
25
26 def set_template_context(self, context: dict[str, Any]) -> None:
27 self._template_context = context
28
29 @cached_property
30 def _frontmatter(self) -> Any:
31 with open(self.absolute_path) as f:
32 return frontmatter.load(f)
33
34 @cached_property
35 def vars(self) -> dict[str, Any]:
36 return self._frontmatter.metadata
37
38 @cached_property
39 def title(self) -> str:
40 default_title = os.path.splitext(os.path.basename(self.relative_path))[0]
41 return self.vars.get("title", default_title)
42
43 def rendered_source(self, context: dict[str, Any] | None = None) -> str:
44 """Render Jinja templates and strip frontmatter, but don't convert markdown to HTML."""
45 content = self._frontmatter.content
46
47 if not self.vars.get("render_plain", False):
48 template = Template(os.path.join("pages", self.relative_path))
49 render_context = context if context is not None else self._template_context
50
51 try:
52 content = template.render(render_context)
53 except Exception as e:
54 # Throw our own error so we don't get shadowed by the Jinja error
55 raise PageRenderError(f"Error rendering page {self.relative_path}: {e}")
56
57 # Strip the frontmatter again, since it was in the template file itself
58 _, content = frontmatter.parse(content)
59
60 return content
61
62 @cached_property
63 def content(self) -> str:
64 content = self.rendered_source()
65
66 if self.is_markdown():
67 content = render_markdown(content, current_page_path=self.relative_path)
68
69 return content
70
71 def is_markdown(self) -> bool:
72 return self._extension == ".md"
73
74 def is_template(self) -> bool:
75 return ".template." in os.path.basename(self.absolute_path)
76
77 def is_asset(self) -> bool:
78 # Anything that we don't specifically recognize for pages
79 # gets treated as an asset
80 return self._extension.lower() not in (
81 ".html",
82 ".md",
83 ".redirect",
84 )
85
86 def is_redirect(self) -> bool:
87 return self._extension == ".redirect"
88
89 def get_template_name(self) -> str:
90 if template_name := self.vars.get("template_name"):
91 return template_name
92
93 return ""
94
95 def get_url_path(self) -> str | None:
96 """Generate the primary URL path for this page."""
97 if self.is_template():
98 return None
99
100 if self.is_asset():
101 return self.relative_path
102
103 url_path = os.path.splitext(self.relative_path)[0]
104
105 # If it's an index.html or something, the url is the parent dir
106 if os.path.basename(url_path) == "index":
107 url_path = os.path.dirname(url_path)
108
109 # The root url should stay an empty string
110 if not url_path:
111 return ""
112
113 # Everything else should get a trailing slash
114 return url_path + "/"
115
116 def get_url_name(self) -> str | None:
117 """Generate the URL name from the URL path."""
118 url_path = self.get_url_path()
119 if url_path is None:
120 return None
121
122 if not url_path:
123 return "index"
124
125 return url_path.rstrip("/")
126
127 def get_view_class(self) -> type:
128 """Get the appropriate view class for this page."""
129 from .views import PageAssetView, PageRedirectView, PageView
130
131 if self.is_redirect():
132 return PageRedirectView
133
134 if self.is_asset():
135 return PageAssetView
136
137 return PageView
138
139 def get_markdown_url(self) -> str | None:
140 """Get the markdown URL for this page if it exists."""
141 if not settings.PAGES_SERVE_MARKDOWN:
142 return None
143
144 url_name = self.get_url_name()
145 if not url_name:
146 return None
147
148 from .registry import pages_registry
149
150 return pages_registry.get_markdown_url(url_name)
151
152 def get_urls(self) -> list[URLPattern]:
153 """Get all URL path objects for this page."""
154 urls = []
155
156 # Generate primary URL
157 url_path = self.get_url_path()
158 url_name = self.get_url_name()
159 view_class = self.get_view_class()
160
161 if url_path is not None and url_name is not None:
162 urls.append(
163 path(
164 url_path,
165 view_class,
166 name=url_name,
167 )
168 )
169
170 # For markdown files, optionally add .md URL
171 if self.is_markdown() and settings.PAGES_SERVE_MARKDOWN:
172 from .views import PageMarkdownView
173
174 urls.append(
175 path(
176 self.relative_path,
177 PageMarkdownView,
178 name=f"{url_name}-md",
179 )
180 )
181
182 return urls