Plain is headed towards 1.0! Subscribe for development updates →

  1import os
  2import re
  3
  4from jinja2 import nodes, pass_context
  5from jinja2.ext import Extension
  6
  7from plain.templates import register_template_extension
  8from plain.utils.safestring import mark_safe
  9
 10
 11@pass_context
 12def Element(ctx, name, **kwargs):
 13    element_path_name = name.replace(".", os.sep)
 14    template = ctx.environment.get_template(f"elements/{element_path_name}.html")
 15
 16    if "caller" in kwargs and "children" not in kwargs:
 17        # If we have a caller, we need to pass it as the children
 18        kwargs["children"] = kwargs["caller"]()
 19
 20    output = template.render(
 21        {
 22            # Note that this passes globals, but not things like loop variables
 23            # so for the most part you need to manually pass the kwargs you want
 24            **ctx.get_all(),
 25            **kwargs,
 26        }
 27    )
 28    return mark_safe(output)
 29
 30
 31@register_template_extension
 32class ElementsExtension(Extension):
 33    tags = {"use_elements"}
 34
 35    def __init__(self, env):
 36        super().__init__(env)
 37        # Make the Element function available in connection with this extension
 38        env.globals["Element"] = Element
 39
 40        self._CAP_TAG = r"(?:[a-z_]+\.)?[A-Z][A-Za-z0-9_]*"
 41
 42        self._SELF = re.compile(
 43            rf"<(?P<name>{self._CAP_TAG})(?P<attrs>(?:\s+[^/>]*?)?)/>"
 44        )
 45        self._CLOSED = re.compile(
 46            rf"<(?P<name>{self._CAP_TAG})(?P<attrs>(?:\s+[^>]*?)?)>"
 47            rf"(?P<body>[\s\S]*?)"
 48            rf"</\1>"
 49        )
 50
 51    def parse(self, parser):
 52        # Consume {% use_elements %} and output nothing
 53        parser.stream.skip()
 54        return nodes.Output([])
 55
 56    def preprocess(self, source, name, filename=None):
 57        if "{% use_elements %}" in source:
 58            # If we have a use_elements tag, we need to replace the template element tags
 59            # with the Element() calls
 60            source = self.replace_template_element_tags(source)
 61
 62        return source
 63
 64    def replace_template_element_tags(self, contents: str):
 65        if not contents:
 66            return contents
 67
 68        def repl_self(m: re.Match) -> str:
 69            return self.convert_element(m.group("name"), m.group("attrs") or "", "")
 70
 71        def repl_closed(m: re.Match) -> str:
 72            body = m.group("body")
 73            if f"<{m.group('name')} " in body:
 74                raise ValueError(
 75                    f"Element {m.group('name')} cannot be nested in itself"
 76                )
 77            return self.convert_element(m.group("name"), m.group("attrs") or "", body)
 78
 79        # keep stripping tags until we can’t find any more
 80        prev = None
 81        while prev != contents:
 82            prev = contents
 83            contents = self._SELF.sub(repl_self, contents)
 84            contents = self._CLOSED.sub(repl_closed, contents)
 85
 86        if re.search(rf"<{self._CAP_TAG}", contents):
 87            raise ValueError("Found unmatched capitalized tag in template")
 88
 89        return contents
 90
 91    def convert_element(self, element_name, s: str, children: str):
 92        attrs: dict[str, str] = {}
 93
 94        # Quoted attrs
 95        for k, v in re.findall(r'([a-zA-Z0-9_]+)="([^"]*)"', s):
 96            attrs[k] = f'"{v}"'
 97        for k, v in re.findall(r"([a-zA-Z0-9_]+)='([^']*)'", s):
 98            attrs[k] = f"'{v}'"
 99
100        # Bare attrs (assume they are strings)
101        for k, v in re.findall(r"([a-zA-Z0-9_]+)=([a-zA-Z0-9_\.]+)", s):
102            attrs[k] = f'"{v}"'
103
104        # Braced Python variables (remove the braces)
105        for k, raw in re.findall(r"([a-zA-Z0-9_]+)=({[^}]*})", s):
106            expr = raw[1:-1]
107            attrs[k] = expr
108
109        attrs_str = ", ".join(f"{k}={v}" for k, v in attrs.items())
110        if attrs_str:
111            attrs_str = ", " + attrs_str
112
113        call = f'{{% call Element("{element_name}"{attrs_str}) %}}{children}{{% endcall %}}'
114        return call.strip()