from __future__ import annotations import os import re from typing import Any from jinja2 import nodes, pass_context from jinja2.environment import Environment from jinja2.ext import Extension from jinja2.parser import Parser from jinja2.runtime import Context from plain.templates import register_template_extension from plain.utils.safestring import SafeString, mark_safe @pass_context def Element(ctx: Context, _element_name: str, **kwargs: Any) -> SafeString: element_path_name = _element_name.replace(".", os.sep) template = ctx.environment.get_template(f"elements/{element_path_name}.html") if "caller" in kwargs and "children" not in kwargs: # If we have a caller, we need to pass it as the children kwargs["children"] = kwargs["caller"]() output = template.render( { # Note that this passes globals, but not things like loop variables # so for the most part you need to manually pass the kwargs you want **ctx.get_all(), **kwargs, } ) return mark_safe(output) @register_template_extension class ElementsExtension(Extension): tags = {"use_elements"} def __init__(self, env: Environment): super().__init__(env) # Make the Element function available in connection with this extension env.globals["Element"] = Element self._CAP_TAG = r"(?:[a-z_]+\.)?[A-Z][A-Za-z0-9_]*" self._SELF = re.compile( rf"<(?P{self._CAP_TAG})(?P(?:\s+(?:[^/>]|/(?!>))*?)?)/>" ) self._CLOSED = re.compile( rf"<(?P{self._CAP_TAG})(?P(?:\s+[^>]*?)?)>" rf"(?P[\s\S]*?)" rf"" ) def parse(self, parser: Parser) -> nodes.Output: # Consume {% use_elements %} and output nothing parser.stream.skip() return nodes.Output([]) def preprocess( self, source: str, name: str | None, filename: str | None = None ) -> str: if "{% use_elements %}" in source: # If we have a use_elements tag, we need to replace the template element tags # with the Element() calls source = self.replace_template_element_tags(source) return source def replace_template_element_tags(self, contents: str) -> str: if not contents: return contents def repl_self(m: re.Match) -> str: return self.convert_element(m.group("name"), m.group("attrs") or "", "") def repl_closed(m: re.Match) -> str: body = m.group("body") if f"<{m.group('name')} " in body: raise ValueError( f"Element {m.group('name')} cannot be nested in itself" ) return self.convert_element(m.group("name"), m.group("attrs") or "", body) # keep stripping tags until we can’t find any more prev = None while prev != contents: prev = contents contents = self._SELF.sub(repl_self, contents) contents = self._CLOSED.sub(repl_closed, contents) if matches := re.search(rf"<{self._CAP_TAG}", contents): raise ValueError( f"Found unmatched capitalized tag in template: {matches.group(0)}" ) return contents def convert_element(self, element_name: str, s: str, children: str) -> str: attrs: dict[str, str] = {} # Quoted attrs for k, v in re.findall(r'([a-zA-Z0-9_]+)="([^"]*)"', s): attrs[k] = f'"{v}"' for k, v in re.findall(r"([a-zA-Z0-9_]+)='([^']*)'", s): attrs[k] = f"'{v}'" # Bare attrs (assume they are strings) for k, v in re.findall(r"([a-zA-Z0-9_]+)=([a-zA-Z0-9_\.]+)", s): attrs[k] = f'"{v}"' # Braced Python variables (remove the braces) for k, raw in re.findall(r"([a-zA-Z0-9_]+)=({[^}]*})", s): expr = raw[1:-1] attrs[k] = expr attrs_str = ", ".join(f"{k}={v}" for k, v in attrs.items()) if attrs_str: attrs_str = ", " + attrs_str call = f'{{% call Element("{element_name}"{attrs_str}) %}}{children}{{% endcall %}}' return call.strip()