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