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