Plain is headed towards 1.0! Subscribe for development updates →

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