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