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