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