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