Plain is headed towards 1.0! Subscribe for development updates →

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