Skip to main content

crepuscularity_web/
lib.rs

1//! HTML and WASM rendering backend for `.crepus` templates.
2//!
3//! Use [`render_from_files`] for virtual-file builds, [`render_template_to_html`] for one-off
4//! templates, and [`render_bundle`] for the `crepus web build` JSON bundle format.
5
6use std::path::Path;
7
8use crepuscularity_core::ast::*;
9use crepuscularity_core::context::{value_to_str, TemplateContext, TemplateValue};
10use crepuscularity_core::eval::eval_expr;
11pub use crepuscularity_core::include_paths::resolve_include_path;
12use crepuscularity_core::parser::{parse_component_file, parse_template};
13use crepuscularity_core::preprocess::{slot_rotate_child_phrases, slot_rotate_words_json_attr};
14use crepuscularity_core::virtual_files::lookup_virtual_file;
15
16mod bundle;
17#[cfg(all(target_arch = "wasm32", feature = "dom"))]
18pub mod dom;
19
20pub use bundle::render_bundle;
21pub use crepuscularity_core::build;
22pub use crepuscularity_core::preprocess::google_fonts_head_markup;
23pub use crepuscularity_macros::crepus_refs;
24
25#[cfg(feature = "ssr")]
26mod ssr;
27
28#[cfg(feature = "ssr")]
29pub use ssr::{
30    render_bundle_with_ssr, render_from_files_with_ssr, render_ssr_document,
31    render_template_to_html_with_ssr, serialize_ctx_for_ssr, SsrDocument,
32};
33
34/// Render an entry point from an in-memory file map — no filesystem access.
35///
36/// `entry` is `"path/to/file.crepus"` or `"path/to/file.crepus#ComponentName"`.
37/// `files` maps paths to `.crepus` source strings.
38/// All `include` directives within the templates are resolved from `files`.
39#[tracing::instrument(skip(files, ctx), fields(entry = entry))]
40pub fn render_from_files(
41    files: &std::collections::HashMap<String, String>,
42    entry: &str,
43    ctx: &TemplateContext,
44) -> Result<String, String> {
45    let mut ctx = ctx.clone();
46    ctx.virtual_files = files.clone();
47
48    if let Some((file_part, comp_name)) = entry.split_once('#') {
49        let content = files
50            .get(file_part)
51            .ok_or_else(|| format!("file not found in virtual fs: {file_part}"))?;
52        return render_component_file_to_html(content, comp_name, &ctx);
53    }
54
55    let content = files
56        .get(entry)
57        .ok_or_else(|| format!("file not found in virtual fs: {entry}"))?;
58    render_template_to_html(content, &ctx)
59}
60
61/// Render multiple entry points from a virtual file map in parallel (requires `parallel` feature).
62///
63/// Returns a `Vec` of `(entry, Result<String, String>)` in the same order as `entries`.
64/// Each entry is independent — rendering runs on a Rayon thread pool.
65/// Falls back to sequential iteration when the `parallel` feature is disabled (e.g. WASM).
66pub fn par_render_from_files(
67    files: &std::collections::HashMap<String, String>,
68    entries: &[&str],
69    ctx: &TemplateContext,
70) -> Vec<(String, Result<String, String>)> {
71    #[cfg(feature = "parallel")]
72    {
73        use rayon::prelude::*;
74        entries
75            .par_iter()
76            .map(|&entry| (entry.to_string(), render_from_files(files, entry, ctx)))
77            .collect()
78    }
79    #[cfg(not(feature = "parallel"))]
80    {
81        entries
82            .iter()
83            .map(|&entry| (entry.to_string(), render_from_files(files, entry, ctx)))
84            .collect()
85    }
86}
87
88/// Render all named components from a multi-component `.crepus` file in parallel.
89///
90/// Returns a `HashMap<component_name, Result<html, error>>`.
91/// Falls back to sequential iteration when the `parallel` feature is disabled.
92pub fn par_render_component_file(
93    content: &str,
94    ctx: &TemplateContext,
95) -> Result<std::collections::HashMap<String, Result<String, String>>, String> {
96    let file = parse_component_file(content)?;
97
98    #[cfg(feature = "parallel")]
99    {
100        use rayon::prelude::*;
101        let results = file
102            .components
103            .par_iter()
104            .map(|(name, comp)| {
105                let mut child_ctx = ctx.clone();
106                for (key, expr) in &comp.meta.defaults {
107                    child_ctx
108                        .vars
109                        .entry(key.clone())
110                        .or_insert_with(|| eval_expr(expr, &TemplateContext::new()));
111                }
112                let html = render_nodes_to_html(&comp.nodes, &child_ctx);
113                (name.clone(), html)
114            })
115            .collect();
116        Ok(results)
117    }
118    #[cfg(not(feature = "parallel"))]
119    {
120        let results = file
121            .components
122            .iter()
123            .map(|(name, comp)| {
124                let mut child_ctx = ctx.clone();
125                for (key, expr) in &comp.meta.defaults {
126                    child_ctx
127                        .vars
128                        .entry(key.clone())
129                        .or_insert_with(|| eval_expr(expr, &TemplateContext::new()));
130                }
131                let html = render_nodes_to_html(&comp.nodes, &child_ctx);
132                (name.clone(), html)
133            })
134            .collect();
135        Ok(results)
136    }
137}
138
139/// Parse a single `.crepus` template string and render it to escaped HTML.
140///
141/// This entry point does not resolve filesystem includes. For component trees that use
142/// `include`, prefer [`render_from_files`] so every source file is supplied through the same
143/// virtual file map used by WASM and static-site builds.
144#[tracing::instrument(skip(template, ctx), fields(template_len = template.len()))]
145pub fn render_template_to_html(template: &str, ctx: &TemplateContext) -> Result<String, String> {
146    let nodes = parse_template(template)?;
147    render_nodes_to_html(&nodes, ctx)
148}
149
150/// Render one named component from a multi-component `.crepus` source string.
151///
152/// `component_name` is the section name after a `--- Name` separator. Component defaults are
153/// evaluated before rendering and do not override variables already present in `ctx`.
154pub fn render_component_file_to_html(
155    content: &str,
156    component_name: &str,
157    ctx: &TemplateContext,
158) -> Result<String, String> {
159    let file = parse_component_file(content)?;
160    let component = file
161        .components
162        .get(component_name)
163        .ok_or_else(|| format!("component not found: {component_name}"))?;
164
165    let mut child_ctx = ctx.clone();
166    for (key, expr) in &component.meta.defaults {
167        child_ctx
168            .vars
169            .entry(key.clone())
170            .or_insert_with(|| eval_expr(expr, &TemplateContext::new()));
171    }
172
173    render_nodes_to_html(&component.nodes, &child_ctx)
174}
175
176/// Render an already parsed AST node list to escaped HTML.
177///
178/// Use this when a caller owns parsing or analysis separately from rendering. `$: let` declarations
179/// are applied in source order to a cloned context, so sibling nodes after a declaration can see it.
180pub fn render_nodes_to_html(nodes: &[Node], ctx: &TemplateContext) -> Result<String, String> {
181    render_nodes_with_ctx(nodes, ctx.clone())
182}
183
184fn render_nodes_with_ctx(nodes: &[Node], mut ctx: TemplateContext) -> Result<String, String> {
185    let _span = tracing::debug_span!("render_html", node_count = nodes.len()).entered();
186    let mut html = String::new();
187
188    for node in nodes {
189        if let Node::LetDecl(decl) = node {
190            if decl.is_default && ctx.vars.contains_key(&decl.name) {
191                continue;
192            }
193            let val = eval_expr(&decl.expr, &ctx);
194            ctx.vars.insert(decl.name.clone(), val);
195            continue;
196        }
197        html.push_str(&render_node(node, &ctx)?);
198    }
199
200    Ok(html)
201}
202
203fn render_node(node: &Node, ctx: &TemplateContext) -> Result<String, String> {
204    match node {
205        Node::Element(el) => render_element(el, ctx),
206        Node::Text(parts) => Ok(escape_html(&render_text(parts, ctx))),
207        Node::If(block) => render_if(block, ctx),
208        Node::For(block) => render_for(block, ctx),
209        Node::Match(block) => render_match(block, ctx),
210        Node::LetDecl(_) => Ok(String::new()),
211        Node::Include(inc) => render_include(inc, ctx),
212        Node::Embed(embed) => render_embed(embed, ctx),
213        Node::RawText(expr) => Ok(escape_html(&value_to_str(&eval_expr(expr, ctx)))),
214    }
215}
216
217fn render_embed(embed: &EmbedNode, ctx: &TemplateContext) -> Result<String, String> {
218    let mut props = serde_json::Map::new();
219    for (key, expr) in &embed.props {
220        props.insert(key.clone(), template_value_to_json(&eval_expr(expr, ctx)));
221    }
222    let props_json = serde_json::Value::Object(props).to_string();
223    let adapter = embed.adapter.as_deref().unwrap_or("module");
224    Ok(format!(
225        "<div data-crepus-island=\"\" data-crepus-island-src=\"{}\" data-crepus-island-adapter=\"{}\" data-crepus-island-props=\"{}\"></div>",
226        escape_html_attr(&embed.src),
227        escape_html_attr(adapter),
228        escape_html_attr(&props_json)
229    ))
230}
231
232fn template_value_to_json(value: &TemplateValue) -> serde_json::Value {
233    match value {
234        TemplateValue::Str(s) => serde_json::Value::String(s.clone()),
235        TemplateValue::Int(n) => serde_json::Value::Number((*n).into()),
236        TemplateValue::Float(f) => serde_json::Number::from_f64(*f)
237            .map(serde_json::Value::Number)
238            .unwrap_or(serde_json::Value::Null),
239        TemplateValue::Bool(b) => serde_json::Value::Bool(*b),
240        TemplateValue::List(items) => serde_json::Value::Array(
241            items
242                .iter()
243                .map(|item| {
244                    let mut object = serde_json::Map::new();
245                    for (key, value) in &item.vars {
246                        object.insert(key.clone(), template_value_to_json(value));
247                    }
248                    serde_json::Value::Object(object)
249                })
250                .collect(),
251        ),
252        TemplateValue::Null => serde_json::Value::Null,
253    }
254}
255
256fn render_element(el: &Element, ctx: &TemplateContext) -> Result<String, String> {
257    if el.tag == "slot" {
258        return if let Some((slot_nodes, slot_ctx)) = &ctx.slot {
259            render_nodes_to_html(slot_nodes, slot_ctx)
260        } else {
261            render_nodes_to_html(&el.children, ctx)
262        };
263    }
264
265    if el.tag == "slot-rotate" {
266        let phrases = slot_rotate_child_phrases(&el.children)?;
267        if phrases.len() < 2 {
268            return Err("slot-rotate needs at least two plain-text phrase children".into());
269        }
270        let mut interval_ms = 3200u64;
271        for b in &el.bindings {
272            if b.prop == "interval" {
273                let v = value_to_str(&eval_expr(&b.value, ctx));
274                let v = v.trim_matches('"').trim();
275                interval_ms = v.parse().unwrap_or(3200);
276            }
277        }
278        let words_json = slot_rotate_words_json_attr(&phrases);
279
280        let mut class_names = vec!["crepus-slot".to_string()];
281        class_names.extend(el.classes.clone());
282        for cc in &el.conditional_classes {
283            if ctx.eval_condition(&cc.condition) {
284                class_names.push(cc.class.clone());
285            }
286        }
287
288        let mut out = String::new();
289        out.push_str("<span");
290        if let Some(id) = &el.id {
291            out.push_str(" id=\"");
292            out.push_str(&escape_html(id));
293            out.push('"');
294        }
295        out.push_str(" class=\"");
296        out.push_str(&escape_html(&ctx.interpolate(&class_names.join(" "))));
297        out.push('"');
298        out.push_str(" data-slot-words=\"");
299        out.push_str(&escape_html(&words_json));
300        out.push('"');
301        out.push_str(" data-slot-interval=\"");
302        out.push_str(&escape_html(&interval_ms.to_string()));
303        out.push('"');
304        out.push_str(" aria-live=\"polite\"");
305
306        for binding in &el.bindings {
307            if binding.prop == "interval" {
308                continue;
309            }
310            out.push(' ');
311            out.push_str(&binding.prop);
312            out.push_str("=\"");
313            let value = value_to_str(&eval_expr(&binding.value, ctx));
314            out.push_str(&escape_html(&value));
315            out.push('"');
316        }
317
318        for handler in &el.event_handlers {
319            out.push(' ');
320            out.push_str("data-on");
321            out.push_str(&handler.event);
322            out.push_str("=\"");
323            out.push_str(&escape_html(&handler.handler));
324            out.push('"');
325        }
326
327        for animation in &el.animations {
328            out.push(' ');
329            out.push_str("data-animate-");
330            out.push_str(&animation.property);
331            out.push_str("=\"");
332            out.push_str(&escape_html(&format!(
333                "{} {}",
334                animation.duration_expr, animation.easing
335            )));
336            out.push('"');
337        }
338
339        out.push_str("></span>");
340        return Ok(out);
341    }
342
343    let mut class_names = el.classes.clone();
344    for cc in &el.conditional_classes {
345        if ctx.eval_condition(&cc.condition) {
346            class_names.push(cc.class.clone());
347        }
348    }
349
350    let mut out = String::new();
351    out.push('<');
352    out.push_str(&el.tag);
353
354    if let Some(id) = &el.id {
355        out.push_str(" id=\"");
356        out.push_str(&escape_html(id));
357        out.push('"');
358    }
359
360    if !class_names.is_empty() {
361        out.push_str(" class=\"");
362        out.push_str(&escape_html(&ctx.interpolate(&class_names.join(" "))));
363        out.push('"');
364    }
365
366    for binding in &el.bindings {
367        out.push(' ');
368        out.push_str(&binding.prop);
369        out.push_str("=\"");
370        let value = value_to_str(&eval_expr(&binding.value, ctx));
371        out.push_str(&escape_html(&value));
372        out.push('"');
373    }
374
375    for handler in &el.event_handlers {
376        out.push(' ');
377        out.push_str("data-on");
378        out.push_str(&handler.event);
379        out.push_str("=\"");
380        out.push_str(&escape_html(&handler.handler));
381        out.push('"');
382    }
383
384    for animation in &el.animations {
385        out.push(' ');
386        out.push_str("data-animate-");
387        out.push_str(&animation.property);
388        out.push_str("=\"");
389        out.push_str(&escape_html(&format!(
390            "{} {}",
391            animation.duration_expr, animation.easing
392        )));
393        out.push('"');
394    }
395
396    out.push('>');
397
398    for child in &el.children {
399        out.push_str(&render_node(child, ctx)?);
400    }
401
402    out.push_str("</");
403    out.push_str(&el.tag);
404    out.push('>');
405    Ok(out)
406}
407
408pub(crate) fn render_text(parts: &[TextPart], ctx: &TemplateContext) -> String {
409    let mut result = String::new();
410    for part in parts {
411        match part {
412            TextPart::Literal(text) => result.push_str(text),
413            TextPart::Expr(expr) => result.push_str(&value_to_str(&eval_expr(expr, ctx))),
414        }
415    }
416    result
417}
418
419fn render_if(block: &IfBlock, ctx: &TemplateContext) -> Result<String, String> {
420    if ctx.eval_condition(&block.condition) {
421        render_nodes_to_html(&block.then_children, ctx)
422    } else if let Some(else_children) = &block.else_children {
423        render_nodes_to_html(else_children, ctx)
424    } else {
425        Ok(String::new())
426    }
427}
428
429fn render_for(block: &ForBlock, ctx: &TemplateContext) -> Result<String, String> {
430    let items = ctx.get_list(&block.iterator);
431    let mut out = String::new();
432
433    for item_ctx in items {
434        let mut child_ctx = ctx.clone();
435        for (k, v) in &item_ctx.vars {
436            child_ctx.vars.insert(k.clone(), v.clone());
437        }
438
439        let pattern = block.pattern.trim();
440        if !pattern.is_empty() {
441            let item_str = item_ctx.get_str("value");
442            if !item_str.is_empty() {
443                child_ctx
444                    .vars
445                    .insert(pattern.to_string(), TemplateValue::Str(item_str));
446            }
447        }
448
449        out.push_str(&render_nodes_to_html(&block.body, &child_ctx)?);
450    }
451
452    Ok(out)
453}
454
455fn render_match(block: &MatchBlock, ctx: &TemplateContext) -> Result<String, String> {
456    let val = eval_expr(&block.expr, ctx);
457    let value = value_to_str(&val);
458
459    for arm in &block.arms {
460        let pattern = arm.pattern.trim();
461        if pattern == "_" {
462            return render_nodes_to_html(&arm.body, ctx);
463        }
464        if pattern.starts_with('"') && pattern.ends_with('"') {
465            let lit = &pattern[1..pattern.len() - 1];
466            if value == lit {
467                return render_nodes_to_html(&arm.body, ctx);
468            }
469        }
470        if value == pattern {
471            return render_nodes_to_html(&arm.body, ctx);
472        }
473    }
474
475    Ok(String::new())
476}
477
478pub(crate) fn read_file(ctx: &TemplateContext, path: &Path) -> Result<String, String> {
479    if let Some(content) = lookup_virtual_file(ctx, path) {
480        return Ok(content);
481    }
482    if cfg!(not(target_arch = "wasm32")) {
483        std::fs::read_to_string(path).map_err(|e| format!("include error: {:?}: {}", path, e))
484    } else {
485        Err(format!(
486            "include error: file not in virtual bundle: {}",
487            path.to_string_lossy()
488        ))
489    }
490}
491
492fn render_include(inc: &IncludeNode, ctx: &TemplateContext) -> Result<String, String> {
493    if let Some((file_part, comp_name)) = inc.path.split_once('#') {
494        return render_named_component(inc, ctx, file_part, comp_name);
495    }
496
497    let file_path = resolve_include_path(ctx.base_dir.as_deref(), &inc.path)?;
498    let content = read_file(ctx, &file_path)?;
499    let nodes = parse_template(&content).map_err(|e| format!("include parse error: {}", e))?;
500
501    let mut child_ctx = TemplateContext::new();
502    child_ctx.base_dir = file_path.parent().map(|p| p.to_path_buf());
503    child_ctx.virtual_files = ctx.virtual_files.clone();
504    for (key, expr) in &inc.props {
505        child_ctx.vars.insert(key.clone(), eval_expr(expr, ctx));
506    }
507    if !inc.slot.is_empty() {
508        child_ctx.slot = Some((inc.slot.clone(), Box::new(ctx.clone())));
509    }
510
511    render_nodes_to_html(&nodes, &child_ctx)
512}
513
514fn render_named_component(
515    inc: &IncludeNode,
516    ctx: &TemplateContext,
517    file_part: &str,
518    comp_name: &str,
519) -> Result<String, String> {
520    let file_path = resolve_include_path(ctx.base_dir.as_deref(), file_part)?;
521    let content = read_file(ctx, &file_path)?;
522    let comp_file =
523        parse_component_file(&content).map_err(|e| format!("component file parse error: {}", e))?;
524    let comp = comp_file
525        .components
526        .get(comp_name)
527        .ok_or_else(|| format!("component '{}' not found in {}", comp_name, file_part))?;
528
529    let mut child_ctx = TemplateContext::new();
530    child_ctx.base_dir = file_path.parent().map(|p| p.to_path_buf());
531    child_ctx.virtual_files = ctx.virtual_files.clone();
532    for (key, expr) in &comp.meta.defaults {
533        child_ctx
534            .vars
535            .insert(key.clone(), eval_expr(expr, &TemplateContext::new()));
536    }
537    for (key, expr) in &inc.props {
538        child_ctx.vars.insert(key.clone(), eval_expr(expr, ctx));
539    }
540    if !inc.slot.is_empty() {
541        child_ctx.slot = Some((inc.slot.clone(), Box::new(ctx.clone())));
542    }
543
544    render_nodes_to_html(&comp.nodes, &child_ctx)
545}
546
547// ── Hydration ─────────────────────────────────────────────────────────────────
548
549/// Returns `true` if a node is "dynamic" — contains interpolated expressions,
550/// control flow, or other content that can change based on context.
551#[cfg(feature = "hydration")]
552fn node_is_dynamic(node: &Node) -> bool {
553    match node {
554        Node::If(_) | Node::For(_) | Node::Match(_) | Node::RawText(_) | Node::Embed(_) => true,
555        Node::Text(parts) => parts
556            .iter()
557            .any(|p| matches!(p, crepuscularity_core::ast::TextPart::Expr(_))),
558        Node::Element(el) => {
559            !el.conditional_classes.is_empty()
560                || !el.bindings.is_empty()
561                || el.children.iter().any(node_is_dynamic)
562        }
563        Node::Include(_) => true,
564        Node::LetDecl(_) => false,
565    }
566}
567
568/// Render a template to HTML with hydration markers injected.
569///
570/// - The root wrapping element gets `data-crepus-root`.
571/// - Each dynamic descendant element gets a unique `data-crepus-id="N"`.
572/// - A non-executable JSON `<script>` is appended with the serialized context
573///   variables encoded as base64.
574///
575/// Enable with the `hydration` cargo feature.
576#[cfg(feature = "hydration")]
577pub fn render_template_to_html_with_hydration(
578    template: &str,
579    ctx: &TemplateContext,
580) -> Result<String, String> {
581    use std::sync::atomic::AtomicU32;
582
583    let nodes = parse_template(template)?;
584
585    // Counter for data-crepus-id assignment.
586    let counter = AtomicU32::new(0);
587
588    let rendered = render_nodes_with_hydration_impl(&nodes, ctx, &counter, /*is_root=*/ true)?;
589
590    // Serialize context vars to JSON.
591    use base64::{engine::general_purpose::STANDARD, Engine as _};
592
593    let ctx_json = serialize_ctx_to_json(ctx);
594    let ctx_b64 = STANDARD.encode(ctx_json.as_bytes());
595    let script = format!(
596        r#"<script id="__crepus_ctx__" type="application/json" data-encoding="base64">{ctx_b64}</script>"#
597    );
598
599    Ok(format!("{rendered}{script}"))
600}
601
602#[cfg(feature = "hydration")]
603fn render_nodes_with_hydration_impl(
604    nodes: &[Node],
605    ctx: &TemplateContext,
606    counter: &std::sync::atomic::AtomicU32,
607    is_root: bool,
608) -> Result<String, String> {
609    use std::sync::atomic::Ordering;
610
611    // Process LetDecl nodes first.
612    let mut ctx = ctx.clone();
613    for node in nodes {
614        if let Node::LetDecl(decl) = node {
615            if decl.is_default && ctx.vars.contains_key(&decl.name) {
616                continue;
617            }
618            let val = crepuscularity_core::eval::eval_expr(&decl.expr, &ctx);
619            ctx.vars.insert(decl.name.clone(), val);
620        }
621    }
622
623    let mut html = String::new();
624    let mut is_first = is_root;
625
626    for node in nodes {
627        if let Node::LetDecl(_) = node {
628            continue;
629        }
630        if let Node::Element(el) = node {
631            let dyn_id = if node_is_dynamic(node) {
632                Some(counter.fetch_add(1, Ordering::Relaxed))
633            } else {
634                None
635            };
636            html.push_str(&render_element_with_hydration(
637                el, &ctx, counter, is_first, dyn_id,
638            )?);
639            is_first = false;
640        } else {
641            html.push_str(&render_node(node, &ctx)?);
642            is_first = false;
643        }
644    }
645
646    Ok(html)
647}
648
649#[cfg(feature = "hydration")]
650fn render_element_with_hydration(
651    el: &crepuscularity_core::ast::Element,
652    ctx: &TemplateContext,
653    counter: &std::sync::atomic::AtomicU32,
654    is_root: bool,
655    dyn_id: Option<u32>,
656) -> Result<String, String> {
657    if el.tag == "slot" {
658        return if let Some((slot_nodes, slot_ctx)) = &ctx.slot {
659            render_nodes_to_html(slot_nodes, slot_ctx)
660        } else {
661            render_nodes_to_html(&el.children, ctx)
662        };
663    }
664
665    let mut class_names = el.classes.clone();
666    for cc in &el.conditional_classes {
667        if ctx.eval_condition(&cc.condition) {
668            class_names.push(cc.class.clone());
669        }
670    }
671
672    let mut out = String::new();
673    out.push('<');
674    out.push_str(&el.tag);
675
676    if is_root {
677        out.push_str(" data-crepus-root");
678    }
679    if let Some(id) = dyn_id {
680        out.push_str(&format!(" data-crepus-id=\"{id}\""));
681    }
682
683    if !class_names.is_empty() {
684        out.push_str(" class=\"");
685        out.push_str(&escape_html(&ctx.interpolate(&class_names.join(" "))));
686        out.push('"');
687    }
688
689    for binding in &el.bindings {
690        out.push(' ');
691        out.push_str(&binding.prop);
692        out.push_str("=\"");
693        let value = crepuscularity_core::context::value_to_str(
694            &crepuscularity_core::eval::eval_expr(&binding.value, ctx),
695        );
696        out.push_str(&escape_html(&value));
697        out.push('"');
698    }
699
700    for handler in &el.event_handlers {
701        out.push(' ');
702        out.push_str("data-on");
703        out.push_str(&handler.event);
704        out.push_str("=\"");
705        out.push_str(&escape_html(&handler.handler));
706        out.push('"');
707    }
708
709    for animation in &el.animations {
710        out.push(' ');
711        out.push_str("data-animate-");
712        out.push_str(&animation.property);
713        out.push_str("=\"");
714        out.push_str(&escape_html(&format!(
715            "{} {}",
716            animation.duration_expr, animation.easing
717        )));
718        out.push('"');
719    }
720
721    out.push('>');
722
723    out.push_str(&render_nodes_with_hydration_impl(
724        &el.children,
725        ctx,
726        counter,
727        false,
728    )?);
729
730    out.push_str("</");
731    out.push_str(&el.tag);
732    out.push('>');
733    Ok(out)
734}
735
736#[cfg(feature = "hydration")]
737fn serialize_ctx_to_json(ctx: &TemplateContext) -> String {
738    use serde_json::{Map, Value};
739    let mut map = Map::new();
740    for (key, val) in &ctx.vars {
741        let json_val = match val {
742            TemplateValue::Str(s) => Value::String(s.clone()),
743            TemplateValue::Int(n) => Value::Number((*n).into()),
744            TemplateValue::Float(f) => serde_json::Number::from_f64(*f)
745                .map(Value::Number)
746                .unwrap_or(Value::Null),
747            TemplateValue::Bool(b) => Value::Bool(*b),
748            TemplateValue::Null => Value::Null,
749            TemplateValue::List(items) => Value::Array(
750                items
751                    .iter()
752                    .map(|item_ctx| {
753                        let mut item_map = Map::new();
754                        for (k, v) in &item_ctx.vars {
755                            item_map.insert(
756                                k.clone(),
757                                match v {
758                                    TemplateValue::Str(s) => Value::String(s.clone()),
759                                    TemplateValue::Int(n) => Value::Number((*n).into()),
760                                    TemplateValue::Float(f) => serde_json::Number::from_f64(*f)
761                                        .map(Value::Number)
762                                        .unwrap_or(Value::Null),
763                                    TemplateValue::Bool(b) => Value::Bool(*b),
764                                    _ => Value::Null,
765                                },
766                            );
767                        }
768                        Value::Object(item_map)
769                    })
770                    .collect(),
771            ),
772        };
773        map.insert(key.clone(), json_val);
774    }
775    serde_json::to_string(&Value::Object(map)).unwrap_or_else(|_| "{}".to_string())
776}
777
778pub(crate) fn escape_html(input: &str) -> String {
779    input
780        .replace('&', "&amp;")
781        .replace('<', "&lt;")
782        .replace('>', "&gt;")
783        .replace('"', "&quot;")
784}
785
786pub(crate) fn escape_html_attr(s: &str) -> String {
787    s.replace('&', "&amp;")
788        .replace('<', "&lt;")
789        .replace('>', "&gt;")
790        .replace('"', "&quot;")
791}