Plain is headed towards 1.0! Subscribe for development updates →

plain.elements

Installation

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