v0.163.0
  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