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