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()