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