1use 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#[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
61pub 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
88pub 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#[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
150pub 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
176pub 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#[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#[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 let counter = AtomicU32::new(0);
587
588 let rendered = render_nodes_with_hydration_impl(&nodes, ctx, &counter, true)?;
589
590 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 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('&', "&")
781 .replace('<', "<")
782 .replace('>', ">")
783 .replace('"', """)
784}
785
786pub(crate) fn escape_html_attr(s: &str) -> String {
787 s.replace('&', "&")
788 .replace('<', "<")
789 .replace('>', ">")
790 .replace('"', """)
791}