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