Add HTML template codegen and runtime for C backend

C codegen (codegen.el):
- cg_html_template: emits a GCC/Clang statement-expression that
  builds the HTML string via el_str_concat chains
- cg_html_element_str / cg_html_parts / cg_html_attrs_str: recursive
  element and attribute emitters
- cg_html_each: {#each} compiles to a C for-loop with el_list_get
- __html_counter state tracks unique accumulator variable names
- Handles both 'static' (raw string) and 'dynamic' (expr node) attrs
  matching the parser's attribute kind convention

Runtime (el_runtime.c / el_runtime.h):
- html_escape(s): escapes & < > " ' for safe interpolation
- html_raw(s): identity function for raw() bypass
- Both use the existing html_buf_t infrastructure from el_html_sanitize
This commit is contained in:
Will Anderson
2026-05-04 13:02:44 -05:00
parent 71689520b6
commit 1fd7cd5545
3 changed files with 211 additions and 0 deletions
+39
View File
@@ -2602,6 +2602,45 @@ el_val_t el_html_sanitize(el_val_t input_v, el_val_t allowlist_v) {
return el_wrap_str(result);
}
/* ── html_escape / html_raw ──────────────────────────────────────────────── */
/*
* html_escape(s) escape a user-supplied string for safe inline interpolation
* in HTML text content or attribute values. Escapes: & < > " '
*
* html_raw(s) identity function; used by the `raw()` escape hatch in El HTML
* templates to explicitly opt out of escaping.
*/
el_val_t html_escape(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s) return EL_STR("");
html_buf_t out;
html_buf_init(&out);
for (const char* p = s; *p; p++) {
unsigned char c = (unsigned char)*p;
switch (c) {
case '&': html_buf_puts(&out, "&amp;"); break;
case '<': html_buf_puts(&out, "&lt;"); break;
case '>': html_buf_puts(&out, "&gt;"); break;
case '"': html_buf_puts(&out, "&quot;"); break;
case '\'': html_buf_puts(&out, "&#39;"); break;
default: html_buf_putc(&out, (char)c); break;
}
}
char* result = el_strbuf(out.len);
memcpy(result, out.data, out.len);
result[out.len] = '\0';
html_buf_free(&out);
return el_wrap_str(result);
}
el_val_t html_raw(el_val_t sv) {
/* Identity — returns the value unchanged. The name exists so generated
* code can call html_raw(expr) instead of expr directly, making it clear
* at the call site that escaping is intentionally bypassed. */
return sv;
}
/* ── JSON ────────────────────────────────────────────────────────────────── */
/* True iff the segment is non-empty and every byte is an ASCII digit. We treat
+7
View File
@@ -214,6 +214,13 @@ el_val_t url_decode(el_val_t s); /* '+' → space, %XX → byte */
* where each value is the array of attribute names allowed for that tag. */
el_val_t el_html_sanitize(el_val_t input_html, el_val_t allowlist_json);
/* ── HTML template helpers ───────────────────────────────────────────────────
* Used by compiled El HTML template expressions.
* html_escape(s) — escape & < > " ' for safe inline interpolation.
* html_raw(s) — identity; explicit opt-out from escaping (`raw()` form). */
el_val_t html_escape(el_val_t s);
el_val_t html_raw(el_val_t s);
/* ── Filesystem ──────────────────────────────────────────────────────────── */
el_val_t fs_read(el_val_t path);
+165
View File
@@ -175,6 +175,167 @@ fn duration_unit_nanos(unit: String) -> String {
"1LL"
}
// ── HTML template codegen ─────────────────────────────────────────────────
//
// cg_html_template(expr) emits a C statement-expression `({ ... })` that
// builds the HTML string by chaining el_str_concat calls.
//
// Interpolated values are passed through html_escape(); the raw() form
// bypasses escaping. {#each} blocks compile to C for-loops that index into
// the list with el_list_get / el_list_len.
//
// A per-template accumulator variable `_html_N` holds the growing string.
// A global counter stored in state keeps names unique.
fn next_html_id() -> String {
let csv: String = state_get("__html_counter")
let n = 0
if !str_eq(csv, "") {
let n = str_to_int(csv)
}
let n = n + 1
state_set("__html_counter", native_int_to_str(n))
native_int_to_str(n)
}
// Emit children nodes into a flat list of C fragment strings (parts).
// Each part is either a static string fragment (already C-literal form) or
// a dynamic expression that produces an el_val_t string.
// We build them all into parts, then the caller wraps with concat chain.
fn cg_html_parts(children: [Map<String, Any>], acc_var: String) -> String {
let n: Int = native_list_len(children)
let i = 0
let out = ""
while i < n {
let child: Map<String, Any> = native_list_get(children, i)
let html_kind: String = child["html"]
if str_eq(html_kind, "Text") {
let text: String = child["text"]
let out = out + acc_var + " = el_str_concat(" + acc_var + ", EL_STR(" + c_str_lit(text) + ")); "
}
if str_eq(html_kind, "Doctype") {
let out = out + acc_var + " = el_str_concat(" + acc_var + ", EL_STR(\"<!doctype html>\")); "
}
if str_eq(html_kind, "Interp") {
let val_node = child["value"]
let val_c: String = cg_expr(val_node)
let out = out + acc_var + " = el_str_concat(" + acc_var + ", html_escape(" + val_c + ")); "
}
if str_eq(html_kind, "Raw") {
let val_node = child["value"]
let val_c: String = cg_expr(val_node)
let out = out + acc_var + " = el_str_concat(" + acc_var + ", html_raw(" + val_c + ")); "
}
if str_eq(html_kind, "Element") {
let elem_c: String = cg_html_element_str(child, acc_var)
let out = out + elem_c
}
if str_eq(html_kind, "Each") {
let each_c: String = cg_html_each(child, acc_var)
let out = out + each_c
}
let i = i + 1
}
out
}
// Generate open-tag attribute fragments inline.
// Parser stores attrs with "kind": "static" | "dynamic" | "bool".
// Static: "value" is the raw string value (not an expr node).
// Dynamic: "value" is an expr node.
// Bool: no "value" field.
fn cg_html_attrs_str(attrs: [Map<String, Any>], acc_var: String) -> String {
let n: Int = native_list_len(attrs)
let i = 0
let out = ""
// Closing-quote snippet: EL_STR("\"") in C text.
let close_q: String = "EL_STR(" + c_str_lit("\"") + ")"
while i < n {
let attr: Map<String, Any> = native_list_get(attrs, i)
let attr_name: String = attr["name"]
let kind: String = attr["kind"]
// Build: EL_STR(" name=\"")
let open_val: String = " " + attr_name + "=\""
let open_attr: String = "EL_STR(" + c_str_lit(open_val) + ")"
if str_eq(kind, "static") {
// Static attribute: value is a raw string.
let sv: String = attr["value"]
let out = out + acc_var + " = el_str_concat(" + acc_var + ", " + open_attr + "); "
let out = out + acc_var + " = el_str_concat(" + acc_var + ", EL_STR(" + c_str_lit(sv) + ")); "
let out = out + acc_var + " = el_str_concat(" + acc_var + ", " + close_q + "); "
} else {
if str_eq(kind, "dynamic") {
// Dynamic attribute: value is an expr node html_escape it.
let val_node = attr["value"]
let val_c: String = cg_expr(val_node)
let out = out + acc_var + " = el_str_concat(" + acc_var + ", " + open_attr + "); "
let out = out + acc_var + " = el_str_concat(" + acc_var + ", html_escape(" + val_c + ")); "
let out = out + acc_var + " = el_str_concat(" + acc_var + ", " + close_q + "); "
} else {
// Boolean attribute (no value): emit " name"
let bool_attr: String = "EL_STR(" + c_str_lit(" " + attr_name) + ")"
let out = out + acc_var + " = el_str_concat(" + acc_var + ", " + bool_attr + "); "
}
}
let i = i + 1
}
out
}
// Generate code for a single element, appending into acc_var.
fn cg_html_element_str(elem: Map<String, Any>, acc_var: String) -> String {
let tag: String = elem["tag"]
let attrs: [Map<String, Any>] = elem["attrs"]
let children: [Map<String, Any>] = elem["children"]
let self_closing: Bool = elem["self_closing"]
// Open tag: <tagname
let out = acc_var + " = el_str_concat(" + acc_var + ", EL_STR(\"<" + tag + "\")); "
let out = out + cg_html_attrs_str(attrs, acc_var)
if self_closing {
// Self-closing void element: />
let out = out + acc_var + " = el_str_concat(" + acc_var + ", EL_STR(\"/>\")); "
} else {
// Close open tag: >
let out = out + acc_var + " = el_str_concat(" + acc_var + ", EL_STR(\">\")); "
let out = out + cg_html_parts(children, acc_var)
let out = out + acc_var + " = el_str_concat(" + acc_var + ", EL_STR(\"</" + tag + ">\")); "
}
out
}
// Generate code for {#each list as item} ... {/each}.
fn cg_html_each(node: Map<String, Any>, acc_var: String) -> String {
let list_expr = node["list"]
let item_name: String = node["item"]
let body_children: [Map<String, Any>] = node["body"]
let id: String = next_html_id()
let list_var: String = "_html_list_" + id
let len_var: String = "_html_len_" + id
let idx_var: String = "_html_i_" + id
let list_c: String = cg_expr(list_expr)
let inner_c: String = cg_html_parts(body_children, acc_var)
// Emit: { el_val_t _list = expr; int _len = el_list_len(_list);
// for (int _i = 0; _i < _len; _i++) {
// el_val_t item = el_list_get(_list, _i); inner_c } }
"{ el_val_t " + list_var + " = (" + list_c + "); el_val_t " + len_var + " = el_list_len(" + list_var + "); for (el_val_t " + idx_var + " = 0; " + idx_var + " < " + len_var + "; " + idx_var + "++) { el_val_t " + item_name + " = el_list_get(" + list_var + ", " + idx_var + "); " + inner_c + "} } "
}
// Top-level HTML template codegen returns a C statement-expression string.
fn cg_html_template(expr: Map<String, Any>) -> String {
let root = expr["root"]
let id: String = next_html_id()
let acc: String = "_html_" + id
// If the root element has doctype:true the parser tagged it from <!doctype html>
let doctype_flag: Bool = root["doctype"]
let doctype_prefix: String = ""
if doctype_flag {
let doctype_prefix = acc + " = el_str_concat(" + acc + ", EL_STR(\"<!doctype html>\")); "
}
let body: String = cg_html_element_str(root, acc)
"({ el_val_t " + acc + " = EL_STR(\"\"); " + doctype_prefix + body + acc + "; })"
}
fn cg_expr(expr: Map<String, Any>) -> String {
let kind: String = expr["expr"]
@@ -787,6 +948,10 @@ fn cg_expr(expr: Map<String, Any>) -> String {
return cg_match(expr)
}
if kind == "HtmlTemplate" {
return cg_html_template(expr)
}
"EL_NULL"
}