Plain is headed towards 1.0! Subscribe for development updates →

  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