""" Renderizado de las plantillas HTML de correo (``plantilla/plantilla-*.html``). Las plantillas usan un subconjunto de Mustache: {{variable}} valor, con escape de HTML {{#nombre}}...{{/nombre}} sección: se repite por cada elemento de una lista, o se muestra una vez si el valor es verdadero {{^nombre}}...{{/nombre}} sección invertida: se muestra si el valor es falso o vacío El renderizado es **estricto**: toda variable o sección debe existir en el contexto (usa ``None``, ``False`` o ``[]`` para ocultar algo). Así un dato que falta provoca un error al renderizar, en vez de un correo con huecos. No soporta ``{{{triple}}}``, parciales, comentarios ni nombres con punto; no se usan en las plantillas. """ import html import os import re CARPETA_PLANTILLAS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "plantilla") _ETIQUETA = re.compile(r"\{\{\s*([#^/]?)\s*([A-Za-z_]\w*)\s*\}\}") class PlantillaError(ValueError): """La plantilla está mal formada o el contexto no tiene algún dato que necesita.""" # ============================================================================ def _analizar(texto: str) -> list: """Convierte el texto en un árbol de nodos: ``("texto", s)``, ``("var", nombre)`` o ``("sec", nombre, hijos, invertida)``.""" raiz: list = [] pila: list[tuple[str, list, bool]] = [] # (nombre, hijos, invertida) de las secciones abiertas actual = raiz posicion = 0 for m in _ETIQUETA.finditer(texto): if m.start() > posicion: actual.append(("texto", texto[posicion:m.start()])) posicion = m.end() marca, nombre = m.group(1), m.group(2) if marca == "": actual.append(("var", nombre)) elif marca in ("#", "^"): hijos: list = [] actual.append(("sec", nombre, hijos, marca == "^")) pila.append((nombre, actual, marca == "^")) actual = hijos else: # cierre linea = texto.count("\n", 0, m.start()) + 1 if not pila or pila[-1][0] != nombre: raise PlantillaError(f"Línea {linea}: '{{{{/{nombre}}}}}' no corresponde a ninguna sección abierta") actual = pila.pop()[1] if pila: raise PlantillaError(f"La sección '{pila[-1][0]}' no se cierra") if posicion < len(texto): actual.append(("texto", texto[posicion:])) return raiz def _buscar(nombre: str, pila: list): for marco in reversed(pila): if isinstance(marco, dict) and nombre in marco: return marco[nombre] raise PlantillaError(f"Falta '{nombre}' en el contexto de la plantilla") def _generar(nodos: list, pila: list) -> str: salida: list[str] = [] for nodo in nodos: tipo = nodo[0] if tipo == "texto": salida.append(nodo[1]) elif tipo == "var": valor = _buscar(nodo[1], pila) salida.append("" if valor is None else html.escape(str(valor), quote=True)) else: _, nombre, hijos, invertida = nodo valor = _buscar(nombre, pila) if invertida: if not valor: salida.append(_generar(hijos, pila)) elif isinstance(valor, list): for elemento in valor: salida.append(_generar(hijos, pila + [elemento])) elif valor: salida.append(_generar(hijos, pila + [valor] if isinstance(valor, dict) else pila)) return "".join(salida) # ============================================================================ def renderizar(texto: str, contexto: dict) -> str: """Renderiza ``texto`` (plantilla Mustache) con ``contexto``. Lanza ``PlantillaError`` si falta algún dato.""" return _generar(_analizar(texto), [contexto]) def nombres_usados(texto: str) -> set[str]: """Todos los nombres (variables y secciones) que usa la plantilla.""" def recorrer(nodos): for n in nodos: if n[0] == "var": yield n[1] elif n[0] == "sec": yield n[1] yield from recorrer(n[2]) return set(recorrer(_analizar(texto))) def cargar_plantilla(nombre: str) -> str: """Lee ``plantilla/plantilla-.html`` (por ejemplo ``aceptado`` o ``rechazado``).""" ruta = os.path.join(CARPETA_PLANTILLAS, f"plantilla-{nombre}.html") with open(ruta, encoding="utf-8") as f: return f.read() def renderizar_plantilla(nombre: str, contexto: dict) -> str: """Carga la plantilla ``nombre`` y la renderiza con ``contexto``.""" return renderizar(cargar_plantilla(nombre), contexto)