1from __future__ import annotations
2
3import os
4from functools import cached_property
5from typing import Any
6
7import frontmatter
8
9from plain.runtime import settings
10from plain.templates import Template
11from plain.urls import URLPattern, path, reverse
12
13from .markdown import render_markdown
14
15
16class PageRenderError(Exception):
17 pass
18
19
20class Page:
21 def __init__(self, relative_path: str, absolute_path: str):
22 self.relative_path = relative_path
23 self.absolute_path = absolute_path
24 self._template_context: dict[str, Any] = {}
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 @cached_property
44 def content(self) -> str:
45 # Strip the frontmatter
46 content = self._frontmatter.content
47
48 if not self.vars.get("render_plain", False):
49 template = Template(os.path.join("pages", self.relative_path))
50
51 try:
52 content = template.render(self._template_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 if self.is_markdown():
61 content = render_markdown(content, current_page_path=self.relative_path)
62
63 return content
64
65 def is_markdown(self) -> bool:
66 extension = os.path.splitext(self.absolute_path)[1]
67 return extension == ".md"
68
69 def is_template(self) -> bool:
70 return ".template." in os.path.basename(self.absolute_path)
71
72 def is_asset(self) -> bool:
73 extension = os.path.splitext(self.absolute_path)[1]
74 # Anything that we don't specifically recognize for pages
75 # gets treated as an asset
76 return extension.lower() not in (
77 ".html",
78 ".md",
79 ".redirect",
80 )
81
82 def is_redirect(self) -> bool:
83 extension = os.path.splitext(self.absolute_path)[1]
84 return extension == ".redirect"
85
86 def get_template_name(self) -> str:
87 if template_name := self.vars.get("template_name"):
88 return template_name
89
90 return ""
91
92 def get_url_path(self) -> str | None:
93 """Generate the primary URL path for this page."""
94 if self.is_template():
95 return None
96
97 if self.is_asset():
98 return self.relative_path
99
100 url_path = os.path.splitext(self.relative_path)[0]
101
102 # If it's an index.html or something, the url is the parent dir
103 if os.path.basename(url_path) == "index":
104 url_path = os.path.dirname(url_path)
105
106 # The root url should stay an empty string
107 if not url_path:
108 return ""
109
110 # Everything else should get a trailing slash
111 return url_path + "/"
112
113 def get_url_name(self) -> str | None:
114 """Generate the URL name from the URL path."""
115 url_path = self.get_url_path()
116 if url_path is None:
117 return None
118
119 if not url_path:
120 return "index"
121
122 return url_path.rstrip("/")
123
124 def get_view_class(self) -> type:
125 """Get the appropriate view class for this page."""
126 from .views import PageAssetView, PageRedirectView, PageView
127
128 if self.is_redirect():
129 return PageRedirectView
130
131 if self.is_asset():
132 return PageAssetView
133
134 return PageView
135
136 def get_markdown_url(self) -> str | None:
137 """Get the markdown URL for this page if it exists."""
138 if not self.is_markdown():
139 return None
140
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 return reverse(f"pages:{url_name}-md")
149
150 def get_urls(self) -> list[URLPattern]:
151 """Get all URL path objects for this page."""
152 urls = []
153
154 # Generate primary URL
155 url_path = self.get_url_path()
156 url_name = self.get_url_name()
157 view_class = self.get_view_class()
158
159 if url_path is not None and url_name is not None:
160 urls.append(
161 path(
162 url_path,
163 view_class,
164 name=url_name,
165 )
166 )
167
168 # For markdown files, optionally add .md URL
169 if self.is_markdown() and settings.PAGES_SERVE_MARKDOWN:
170 from .views import PageMarkdownView
171
172 urls.append(
173 path(
174 self.relative_path,
175 PageMarkdownView,
176 name=f"{url_name}-md",
177 )
178 )
179
180 return urls