Plain is headed towards 1.0! Subscribe for development updates →

plain.elements

Installation

# settings.py
JINJA_LOADER = "plain.elements.ElementsLoader"
  1import os
  2import re
  3from typing import TYPE_CHECKING
  4
  5from jinja2 import nodes
  6from jinja2.ext import Extension
  7from jinja2.loaders import FileSystemLoader
  8
  9from plain.runtime import settings
 10from plain.utils.functional import cached_property
 11
 12if TYPE_CHECKING:
 13    from jinja2 import Environment
 14
 15
 16class ElementsLoader(FileSystemLoader):
 17    def get_source(self, environment: "Environment", template: str):
 18        contents, path, uptodate = super().get_source(environment, template)
 19
 20        # Clear elements cache if it looks like a element changed
 21        # if os.path.splitext(path)[1] == ".html" and "elements" in path and "template_elements" in self.__dict__:
 22        #     del self.__dict__["template_elements"]
 23
 24        # If it's html or markdown, replace element tags
 25        if os.path.splitext(path)[1] in [".html", ".md"]:
 26            self._elements_environment = (
 27                environment  # Save this so we can use it in template_elements
 28            )
 29            contents = self.replace_template_element_tags(contents)
 30
 31        return contents, path, uptodate
 32
 33    @cached_property
 34    def template_elements(self):
 35        elements = []
 36
 37        for searchpath in self.searchpath:
 38            elements_dir = os.path.join(searchpath, "elements")
 39            if os.path.isdir(elements_dir):
 40                for root, dirs, files in os.walk(elements_dir):
 41                    for file in files:
 42                        relative_path = os.path.relpath(
 43                            os.path.join(root, file), elements_dir
 44                        )
 45                        # Replace slashes with .
 46                        element_name = os.path.splitext(relative_path)[0].replace(
 47                            os.sep, "."
 48                        )
 49                        elements.append(
 50                            {
 51                                "path": relative_path,
 52                                "html_name": element_name,  # Uses . syntax
 53                                "tag_name": element_name.replace(
 54                                    ".", "_"
 55                                ),  # Uses _ syntax
 56                            }
 57                        )
 58
 59        for element in elements:
 60            element_name = element["html_name"]
 61            jinja_tag_name = element["tag_name"]
 62            element_relative_path = element["path"]
 63
 64            class ElementExtension(Extension):
 65                def parse(self, parser):
 66                    lineno = next(parser.stream).lineno
 67                    args = [
 68                        nodes.DerivedContextReference(),
 69                    ]
 70                    kwargs = []
 71                    while parser.stream.current.type != "block_end":
 72                        if parser.stream.current.type == "name":
 73                            key = parser.stream.current.value
 74                            parser.stream.skip()
 75                            parser.stream.expect("assign")
 76                            value = parser.parse_expression()
 77                            kwargs.append(nodes.Keyword(key, value))
 78
 79                    body = parser.parse_statements(
 80                        ["name:end" + self.jinja_tag_name], drop_needle=True
 81                    )
 82
 83                    call = self.call_method(
 84                        "_render", args=args, kwargs=kwargs, lineno=lineno
 85                    )
 86
 87                    self.source_ref = f"{parser.name}:{lineno}"
 88
 89                    return nodes.CallBlock(call, [], [], body).set_lineno(lineno)
 90
 91                def _render(self, context, **kwargs):
 92                    template = self.environment.get_template(self.template_name)
 93                    rendered = template.render({**context, **kwargs})
 94
 95                    if settings.DEBUG:
 96                        # Add an HTML comment in dev to help identify elements in output
 97                        return f"<!-- <{self.html_name}>\n{self.source_ref} -->\n{rendered}\n<!-- </{self.html_name}> -->"
 98                    else:
 99                        return rendered
100
101            # Create a new class on the fly
102            NamedElementExtension = type(
103                f"PlainElement.{element_name}",
104                (ElementExtension,),
105                {
106                    "tags": {jinja_tag_name, f"end{jinja_tag_name}"},
107                    "template_name": f"elements/{element_relative_path}",
108                    "jinja_tag_name": jinja_tag_name,
109                    "html_name": element_name,
110                },
111            )
112            self._elements_environment.add_extension(NamedElementExtension)
113
114        return elements
115
116    def replace_template_element_tags(self, contents: str):
117        def replace_quoted_braces(s) -> str:
118            """
119            We're converting to tag syntax, but it's very natural to write
120            <Label for="{{ thing }}"> vs <Label for=thing>
121            so we just convert the first to the second automatically.
122            """
123            return re.sub(r"(?<=\"{{)(.+)(?=}}\")", r"\1", s)
124
125        for element in self.template_elements:
126            element_name = element["html_name"]
127            jinja_tag_name = element["tag_name"]
128
129            closing_pattern = re.compile(
130                rf"<{element_name}(\s+[\s\S]*?)?>([\s\S]*?)</{element_name}>"
131            )
132            self_closing_pattern = re.compile(rf"<{element_name}(\s+[\s\S]*?)?/>")
133
134            def closing_cb(match: re.Match) -> str:
135                if f"<{element_name}" in match.group(2):
136                    raise ValueError(
137                        f"Element {element_name} cannot be nested in itself"
138                    )
139
140                attrs_str = match.group(1) or ""
141                inner = match.group(2)
142
143                attrs_str = replace_quoted_braces(attrs_str)
144                return f"{{% {jinja_tag_name} {attrs_str} %}}{inner}{{% end{jinja_tag_name} %}}"
145
146            contents = closing_pattern.sub(closing_cb, contents)
147
148            def self_closing_cb(match: re.Match) -> str:
149                attrs_str = match.group(1) or ""
150
151                attrs_str = replace_quoted_braces(attrs_str)
152                return (
153                    f"{{% {jinja_tag_name} {attrs_str} %}}{{% end{jinja_tag_name} %}}"
154                )
155
156            contents = self_closing_pattern.sub(self_closing_cb, contents)
157
158        if match := re.search(r"<[A-Z].*>", contents):
159            raise ValueError(
160                f"Found unmatched uppercase tag in template: {match.group(0)}"
161            )
162
163        if match := re.search(r"<[a-z_]+\.[A-Z]+.*>", contents):
164            raise ValueError(
165                f"Found unmatched nested tag in template: {match.group(0)}"
166            )
167
168        return contents