You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

545 lines
19 KiB

/*
* A minimal Jinja2-like template engine in one file, supporting:
* - {% extends "base.html" %}
* - {% block content %} ... {% endblock %}
* - {{ scalarVariable }}
* - {% if expr %} ... {% elif expr %} ... {% else %} ... {% endif %}
* - {% for item in list %} ... {% endfor %}
* - Basic expression parsing with ==, !=, >, <, >=, <=
* - Simple filter usage: {{ var|add:-1 }}
*
* Updated to support nested variable access using dot notation (e.g., repo.packaging_commit).
*
* Copyright (C) 2024-2025 Simon Quigley <tsimonq2@ubuntu.com>
*/
#include "template_renderer.h"
#include <string>
#include <vector>
#include <map>
#include <regex>
#include <fstream>
#include <sstream>
#include <iostream>
#include <filesystem>
#include <stdexcept>
#include <cstdlib>
#include <algorithm>
#include <exception>
#include <shared_mutex>
#include <mutex>
namespace fs = std::filesystem;
static std::mutex file_mutex;
std::string TemplateRenderer::build_template_path(const std::string &tplName)
{
if (!tplName.empty() && tplName.front() == '/') {
return tplName;
}
return "templates/" + tplName;
}
std::string TemplateRenderer::file_get_contents(const std::string &path)
{
std::unique_lock lock(file_mutex);
try {
fs::path rel(path);
fs::path abs = fs::absolute(rel);
auto open_file = [](const fs::path& file_path) -> std::ifstream {
std::ifstream file(file_path, std::ios::in);
if (!file) {
throw std::ios_base::failure("File could not be opened: " + file_path.string());
}
return file;
};
std::ifstream file = open_file(abs);
std::ostringstream contents;
contents << file.rdbuf();
return contents.str();
} catch (const std::exception& e) {
std::cerr << "Unable to get file contents in template_renderer: " << e.what() << "\n";
return "";
} catch (...) {
std::cerr << "Unable to get file contents in template_renderer (unknown exception.)\n";
return "";
}
}
std::string TemplateRenderer::apply_filter(const std::string &value, const std::string &filterPart)
{
size_t colonPos = filterPart.find(':');
std::string filterName = (colonPos == std::string::npos)
? filterPart
: filterPart.substr(0, colonPos);
std::string filterArg = (colonPos == std::string::npos)
? ""
: filterPart.substr(colonPos + 1);
if (filterName == "add") {
try {
int original = std::stoi(value);
int increment = std::stoi(filterArg);
return std::to_string(original + increment);
} catch(...) {
return value;
}
}
// Additional filters can be added here.
return value; // Unknown filter => pass through
}
std::string TemplateRenderer::apply_all_filters(const std::string &valueWithFilters,
const std::map<std::string,std::string> &ctx)
{
// Split on '|'
std::vector<std::string> parts;
size_t start = 0;
while (true) {
size_t pos = valueWithFilters.find('|', start);
if (pos == std::string::npos) {
parts.push_back(valueWithFilters.substr(start));
break;
}
parts.push_back(valueWithFilters.substr(start, pos - start));
start = pos + 1;
}
if (parts.empty()) {
return "";
}
std::string varExpression = parts[0];
std::string value = get_variable_value(varExpression, ctx);
// Apply filters if any
for (size_t i = 1; i < parts.size(); i++) {
value = apply_filter(value, parts[i]);
}
return value;
}
bool TemplateRenderer::evaluate_condition(const std::string &expr,
const std::map<std::string,std::string> &ctx)
{
// Define helper lambdas
auto trim = [](const std::string &s) -> std::string {
size_t start = 0;
while (start < s.size() && isspace(static_cast<unsigned char>(s[start]))) start++;
size_t end = s.size();
while (end > start && isspace(static_cast<unsigned char>(s[end - 1]))) end--;
return s.substr(start, end - start);
};
auto isInteger = [&](const std::string &s) -> bool {
if (s.empty()) return false;
size_t start = (s[0] == '-') ? 1 : 0;
for (size_t i = start; i < s.size(); ++i) {
if (!isdigit(static_cast<unsigned char>(s[i]))) return false;
}
return true;
};
auto unquoteIfNeeded = [&](const std::string &tok) -> std::string {
auto t = trim(tok);
if (t.size() >= 2 &&
((t.front() == '\'' && t.back() == '\'') ||
(t.front() == '\"' && t.back() == '\"'))) {
return t.substr(1, t.size() - 2);
}
return t;
};
auto parse_token_value = [&](const std::string &rawToken) -> std::string {
auto t = trim(rawToken);
if (t.size() >= 2 && ((t.front() == '\'' && t.back() == '\'') ||
(t.front() == '\"' && t.back() == '\"'))) {
// Literal string
return unquoteIfNeeded(t);
} else {
// Apply filters
return apply_all_filters(t, ctx);
}
};
// Split the expression by 'and'
std::vector<std::string> conditions;
std::regex andRe("\\s+and\\s+");
std::sregex_token_iterator it(expr.begin(), expr.end(), andRe, -1);
std::sregex_token_iterator end;
while (it != end) {
conditions.push_back(trim(*it));
++it;
}
// Evaluate each sub-condition
for (const auto &subExpr : conditions) {
std::string e = trim(subExpr);
if (e.empty()) continue;
// Operators
static std::vector<std::string> ops = {"==", "!=", "<=", ">=", ">", "<"};
size_t opPos = std::string::npos;
std::string opFound;
for (const auto &cand : ops) {
size_t p = e.find(cand);
if (p != std::string::npos) {
if (opPos == std::string::npos || p < opPos) {
opPos = p;
opFound = cand;
}
}
}
if (opPos == std::string::npos) {
// No operator => check truthiness of var
std::string val = parse_token_value(e);
if (val.empty()) return false;
continue;
}
std::string left = trim(e.substr(0, opPos));
std::string right = trim(e.substr(opPos + opFound.size()));
// Directly handle dot notation by using the entire composite key
std::string lv = parse_token_value(left);
std::string rv = parse_token_value(right);
bool li = isInteger(lv);
bool ri = isInteger(rv);
bool result = false;
if (li && ri) {
int lnum = std::stoi(lv);
int rnum = std::stoi(rv);
if (opFound == "==") result = (lnum == rnum);
else if (opFound == "!=") result = (lnum != rnum);
else if (opFound == ">") result = (lnum > rnum);
else if (opFound == "<") result = (lnum < rnum);
else if (opFound == ">=") result = (lnum >= rnum);
else if (opFound == "<=") result = (lnum <= rnum);
} else {
// String compare
if (opFound == "==") result = (lv == rv);
else if (opFound == "!=") result = (lv != rv);
else if (opFound == ">") result = (lv > rv);
else if (opFound == "<") result = (lv < rv);
else if (opFound == ">=") result = (lv >= rv);
else if (opFound == "<=") result = (lv <= rv);
}
if (!result) return false; // Short-circuit for 'and'
}
return true; // All sub-conditions passed
}
std::string TemplateRenderer::expand_conditionals(std::string input,
const std::map<std::string,std::string> &ctx)
{
static std::regex ifOpenRe("\\{\\%\\s*if\\s+[^\\}]+\\%\\}");
static std::regex ifCloseRe("\\{\\%\\s*endif\\s*\\%\\}");
while (true) {
// Gather all if-positions
std::vector<size_t> ifPositions;
{
size_t searchStart = 0;
while (true) {
std::smatch mOpen;
std::string sub = input.substr(searchStart);
if (!std::regex_search(sub, mOpen, ifOpenRe)) {
break;
}
size_t posAbsolute = searchStart + mOpen.position(0);
ifPositions.push_back(posAbsolute);
searchStart = posAbsolute + mOpen.length(0);
}
}
if (ifPositions.empty()) {
break;
}
// The last one is the innermost
size_t ifPos = ifPositions.back();
{
std::string sub2 = input.substr(ifPos);
std::smatch mclose;
if (!std::regex_search(sub2, mclose, ifCloseRe)) {
// No matching endif
break;
}
size_t closePosRelative = mclose.position(0);
size_t ifClosePos = ifPos + closePosRelative;
size_t blockLen = (ifClosePos - ifPos) + mclose.length(0);
// Entire block
std::string blockText = input.substr(ifPos, blockLen);
// Main regex to match the entire if-endif block
static std::regex mainRe(
"\\{\\%\\s*if\\s+([^\\}]+)\\s*\\%\\}([\\s\\S]*?)\\{\\%\\s*endif\\s*\\%\\}"
);
std::smatch blockMatch;
if (!std::regex_match(blockText, blockMatch, mainRe)) {
break;
}
std::string condition = blockMatch[1].str();
std::string innerBlock = blockMatch[2].str();
// Parse out any {% elif ... %} / {% else %}
struct ConditionBlock {
std::string cond; // Empty => else
std::string content;
};
std::vector<ConditionBlock> blocks;
blocks.emplace_back(ConditionBlock{ condition, "" });
static std::regex elifElseRe("\\{\\%\\s*elif\\s+([^\\}]+)\\s*\\%\\}|\\{\\%\\s*else\\s*\\%\\}");
size_t lastPos = 0;
auto bBegin = std::sregex_iterator(innerBlock.begin(), innerBlock.end(), elifElseRe);
auto bEnd = std::sregex_iterator();
for (auto i = bBegin; i != bEnd; ++i) {
auto m2 = *i;
size_t pos2 = m2.position(0);
// Text up to pos2 is the previous block's content
blocks.back().content.append(innerBlock.substr(lastPos, pos2 - lastPos));
if (m2[1].matched) {
// Elif
blocks.emplace_back(ConditionBlock{ m2[1].str(), "" });
} else {
// Else
blocks.emplace_back(ConditionBlock{ "", "" });
}
lastPos = pos2 + m2.length(0);
}
// Leftover
if (!blocks.empty()) {
blocks.back().content.append(innerBlock.substr(lastPos));
}
// Evaluate
std::string finalText;
bool used = false;
for (auto &b : blocks) {
if (b.cond.empty()) {
// Else
if (!used) {
finalText = b.content;
}
break;
} else {
if (evaluate_condition(b.cond, ctx)) {
finalText = b.content;
used = true;
break;
}
}
}
// Replace that block region with finalText
input.replace(ifPos, blockLen, finalText);
}
}
return input;
}
std::string TemplateRenderer::expand_loops(const std::string &input,
const std::map<std::string,std::string> &scalarContext,
const std::map<std::string,
std::vector<std::map<std::string,std::string>>> &listContext)
{
std::string result = input;
static std::regex loopRegex("\\{\\%\\s*for\\s+(\\S+)\\s+in\\s+(\\S+)\\s*\\%\\}([\\s\\S]*?)\\{\\%\\s*endfor\\s*\\%\\}");
while (true) {
std::smatch m;
if (!std::regex_search(result, m, loopRegex)) {
break;
}
std::string aliasName = m[1].str(); // e.g., 'repo'
std::string arrayName = m[2].str(); // e.g., 'repos'
std::string loopBody = m[3].str();
auto it = listContext.find(arrayName);
if (it == listContext.end()) {
// No such array => remove the block
result.replace(m.position(0), m.length(0), "");
continue;
}
std::string expanded;
for (const auto &oneItem : it->second) {
// Create a per-item scalar context with prefixed keys
std::map<std::string, std::string> perItemScalarContext = scalarContext;
for (const auto &kv : oneItem) {
perItemScalarContext[aliasName + "." + kv.first] = kv.second;
}
std::string chunk = loopBody;
// Expand conditionals with per-item scalar context
chunk = expand_conditionals(chunk, perItemScalarContext);
// Expand nested loops if any with per-item scalar context
chunk = expand_loops(chunk, perItemScalarContext, listContext);
// Final scalar expansions with per-item scalar context
chunk = replace_variables(chunk, perItemScalarContext);
// Remove excess whitespace
chunk = strip_excess_whitespace(chunk);
expanded += chunk;
}
result.replace(m.position(0), m.length(0), expanded);
}
return result;
}
std::string TemplateRenderer::replace_variables(const std::string &input,
const std::map<std::string,std::string> &context)
{
static std::regex varRe("\\{\\{\\s*(.*?)\\s*\\}\\}");
std::string output;
output.reserve(input.size());
size_t lastPos = 0;
auto begin = std::sregex_iterator(input.begin(), input.end(), varRe);
auto end = std::sregex_iterator();
for (auto it = begin; it != end; ++it) {
auto match = *it;
output.append(input, lastPos, match.position(0) - lastPos);
std::string expr = match[1].str();
// Directly apply all filters (which now handle composite keys)
std::string value = apply_all_filters(expr, context);
output.append(value);
lastPos = match.position(0) + match.length(0);
}
output.append(input, lastPos);
// Remove leftover {% ... %} if any
static std::regex leftover("\\{\\%.*?\\%\\}");
output = std::regex_replace(output, leftover, "");
return output;
}
std::string TemplateRenderer::render_jinja(
const std::string &tplPath,
const std::map<std::string,std::string> &scalarContext,
const std::map<std::string,
std::vector<std::map<std::string,std::string>>> &listContext)
{
std::string tpl = file_get_contents(tplPath);
if (tpl.empty()) {
return "<html><body><p>Template not found: " + tplPath + "</p></body></html>";
}
std::string step0 = expand_conditionals(tpl, scalarContext);
std::string step1 = expand_loops(step0, scalarContext, listContext);
std::string result = replace_variables(step1, scalarContext);
return result;
}
std::string TemplateRenderer::render_with_inheritance(
const std::string &childTplName,
const std::map<std::string,std::string> &scalarContext,
const std::map<std::string,
std::vector<std::map<std::string,std::string>>> &listContext)
{
// Load child template
std::string childText = file_get_contents(build_template_path(childTplName));
if (childText.empty()) {
return "<html><body><h1>Missing child template:</h1>"
+ build_template_path(childTplName) + "</body></html>";
}
// Check for {% extends "base.html" %}
static std::regex extendsRe("\\{\\%\\s*extends\\s*\"([^\"]+)\"\\s*\\%\\}");
std::smatch exm;
if (!std::regex_search(childText, exm, extendsRe)) {
// No extends => just do expansions
std::string step0 = expand_conditionals(childText, scalarContext);
std::string step1 = expand_loops(step0, scalarContext, listContext);
std::string result = replace_variables(step1, scalarContext);
return result;
}
// If extends => load base
std::string baseName = exm[1].str();
std::string baseText = file_get_contents(build_template_path(baseName));
if (baseText.empty()) {
return "<html><body><h1>Missing base template:</h1>"
+ baseName + "</body></html>";
}
// Extract child block content
static std::regex blockRe("\\{\\%\\s*block\\s+content\\s*\\%\\}([\\s\\S]*?)\\{\\%\\s*endblock\\s*\\%\\}");
std::smatch blockMatch;
std::string childBlock;
if (std::regex_search(childText, blockMatch, blockRe)) {
childBlock = blockMatch[1].str();
}
// Process loops first, which handle their own conditionals with loop variables
std::string expandedChildBlock = expand_loops(childBlock, scalarContext, listContext);
// Then process any conditionals outside loops
expandedChildBlock = expand_conditionals(expandedChildBlock, scalarContext);
// Finally, replace variables in the child block
expandedChildBlock = replace_variables(expandedChildBlock, scalarContext);
// Replace {{BLOCK content}} in base with expanded child block
const std::string marker = "{{BLOCK content}}";
size_t pos = baseText.find(marker);
if (pos != std::string::npos) {
baseText.replace(pos, marker.size(), expandedChildBlock);
}
// Replace variables in the entire base template (to handle {{PAGE_TITLE}})
baseText = replace_variables(baseText, scalarContext);
// Remove any remaining {% ... %} tags
static std::regex leftover("\\{\\%.*?\\%\\}");
baseText = std::regex_replace(baseText, leftover, "");
return baseText;
}
std::string TemplateRenderer::strip_excess_whitespace(const std::string &str) {
// Remove leading/trailing spaces and unify consecutive whitespace into single spaces
std::string result;
result.reserve(str.size());
bool prevSpace = false;
for (char c: str) {
if (isspace(static_cast<unsigned char>(c))) {
if (!prevSpace) {
result += ' ';
prevSpace = true;
}
} else {
result += c;
prevSpace = false;
}
}
// Trim leading and trailing spaces
size_t start = 0;
while (start < result.size() && isspace(static_cast<unsigned char>(result[start]))) {
start++;
}
size_t end = result.size();
while (end > start && isspace(static_cast<unsigned char>(result[end - 1]))) {
end--;
}
return result.substr(start, end - start);
}
std::string TemplateRenderer::get_variable_value(const std::string &var,
const std::map<std::string, std::string> &ctx) {
auto it = ctx.find(var);
if (it != ctx.end()) {
return it->second;
}
return "";
}