Plain is headed towards 1.0! Subscribe for development updates →

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