use wasm_bindgen::{JsCast, JsValue};
use web_sys::{console, Element, Node, Text};
use crate::expr::{self, Spanned};
use crate::mount::track_effect_on;
use crate::reactive::effect;
use crate::scope::with_current_el;
enum Segment {
Static(String),
Dynamic(String),
}
#[doc(hidden)]
pub enum PlannedSegment {
Static(&'static str),
Dynamic(&'static str),
}
pub fn scan_children(parent: &Element, proxy: &JsValue) {
if parent.has_attribute("pp-text") {
return;
}
if parent.has_attribute("data-pp-text-managed") {
return;
}
if parent.has_attribute("data-pp-interp-managed") {
return;
}
let nodes = parent.child_nodes();
let mut texts: Vec<Text> = Vec::new();
for i in 0..nodes.length() {
if let Some(n) = nodes.item(i) {
if n.node_type() == Node::TEXT_NODE {
if let Ok(t) = n.dyn_into::<Text>() {
texts.push(t);
}
}
}
}
for text in texts {
let Some(data) = text.node_value() else {
continue;
};
if !data.contains("{{") {
continue;
}
let segments = match parse_segments(&data) {
Ok(s) => s,
Err(err) => {
console::error_1(&JsValue::from_str(&format!(
"text interpolation: {err} in {data:?}"
)));
continue;
}
};
if segments.iter().all(|s| matches!(s, Segment::Static(_))) {
continue;
}
install(parent, proxy, &text, segments);
}
}
#[doc(hidden)]
pub fn resolve_text_target(parent: &Element, text_index: usize) -> Option<Text> {
let nodes = parent.child_nodes();
let mut seen: usize = 0;
for i in 0..nodes.length() {
let Some(n) = nodes.item(i) else { continue };
if n.node_type() != Node::TEXT_NODE {
continue;
}
if seen == text_index {
return n.dyn_into::<Text>().ok();
}
seen += 1;
}
None
}
#[doc(hidden)]
pub fn install_planned_target(
parent: &Element,
proxy: &JsValue,
target: &Text,
segments: &'static [PlannedSegment],
) {
let runtime_segments: Vec<Segment> = segments
.iter()
.map(|s| match s {
PlannedSegment::Static(t) => Segment::Static((*t).to_string()),
PlannedSegment::Dynamic(src) => Segment::Dynamic((*src).to_string()),
})
.collect();
install(parent, proxy, target, runtime_segments);
}
fn install(parent: &Element, proxy: &JsValue, original: &Text, segments: Vec<Segment>) {
let parent_node: &Node = parent.as_ref();
for seg in segments {
match seg {
Segment::Static(s) => {
let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
return;
};
let node = doc.create_text_node(&s);
let _ = parent_node.insert_before(node.as_ref(), Some(original.as_ref()));
}
Segment::Dynamic(src) => {
let ast: Spanned<expr::Expr> = match expr::parse_cached(&src) {
Ok(a) => a,
Err(e) => {
console::error_1(&JsValue::from_str(&format!(
"interpolation `{{{{{src}}}}}`: {} (at {}..{})",
e.message, e.span.start, e.span.end
)));
let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
return;
};
let fallback = doc.create_text_node(&format!("{{{{{src}}}}}"));
let _ =
parent_node.insert_before(fallback.as_ref(), Some(original.as_ref()));
continue;
}
};
let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
return;
};
let node = doc.create_text_node("");
let _ = parent_node.insert_before(node.as_ref(), Some(original.as_ref()));
let proxy = proxy.clone();
let node_clone = node.clone();
let el_for_magic = parent.clone();
let id = effect(move || {
with_current_el(&el_for_magic, || {
let v = expr::evaluate(&ast, &proxy);
node_clone.set_data(&js_to_string(&v));
});
});
track_effect_on(parent, id);
}
}
}
let _ = parent_node.remove_child(original.as_ref());
}
fn parse_segments(input: &str) -> Result<Vec<Segment>, String> {
let mut out = Vec::new();
let bytes = input.as_bytes();
let mut i = 0;
let mut static_buf = String::new();
while i < bytes.len() {
let b = bytes[i];
if b == b'\\' && i + 2 < bytes.len() {
let n1 = bytes[i + 1];
let n2 = bytes[i + 2];
if (n1 == b'{' && n2 == b'{') || (n1 == b'}' && n2 == b'}') {
static_buf.push(n1 as char);
static_buf.push(n2 as char);
i += 3;
continue;
}
}
if b == b'\\' && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
static_buf.push('\\');
i += 2;
continue;
}
if b == b'{' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
if !static_buf.is_empty() {
out.push(Segment::Static(std::mem::take(&mut static_buf)));
}
let start = i + 2;
let mut j = start;
let mut found = false;
while j + 1 < bytes.len() {
if bytes[j] == b'}' && bytes[j + 1] == b'}' {
found = true;
break;
}
j += 1;
}
if !found {
return Err("unclosed `{{` in text".into());
}
let src = std::str::from_utf8(&bytes[start..j])
.map_err(|_| "non-UTF-8 text")?
.trim()
.to_string();
if src.is_empty() {
return Err("empty `{{}}` interpolation".into());
}
out.push(Segment::Dynamic(src));
i = j + 2;
continue;
}
static_buf.push(b as char);
i += 1;
}
if !static_buf.is_empty() {
out.push(Segment::Static(static_buf));
}
Ok(out)
}
fn js_to_string(v: &JsValue) -> String {
if v.is_undefined() || v.is_null() {
return String::new();
}
v.as_string()
.or_else(|| v.as_f64().map(|n| n.to_string()))
.or_else(|| v.as_bool().map(|b| b.to_string()))
.unwrap_or_else(|| {
js_sys::JSON::stringify(v)
.ok()
.and_then(|s| s.as_string())
.unwrap_or_default()
})
}
#[cfg(test)]
mod tests {
use super::{parse_segments, Segment};
fn render(segs: &[Segment]) -> String {
let mut s = String::new();
for seg in segs {
match seg {
Segment::Static(t) => s.push_str(t),
Segment::Dynamic(t) => s.push_str(&format!("<<{t}>>")),
}
}
s
}
#[test]
fn single_braces_are_literal() {
let segs = parse_segments("Rust: fn foo() { x }").unwrap();
assert_eq!(render(&segs), "Rust: fn foo() { x }");
assert!(segs.iter().all(|s| matches!(s, Segment::Static(_))));
}
#[test]
fn double_braces_interpolate() {
let segs = parse_segments("Hi {{name}}, {{count}}!").unwrap();
assert_eq!(render(&segs), "Hi <<name>>, <<count>>!");
}
#[test]
fn escaped_double_brace_is_literal() {
let segs = parse_segments(r"\{{literal}}").unwrap();
assert_eq!(render(&segs), "{{literal}}");
}
#[test]
fn unclosed_errors() {
assert!(parse_segments("oops {{foo").is_err());
}
#[test]
fn empty_errors() {
assert!(parse_segments("{{}}").is_err());
}
#[test]
fn mixed_with_code_samples() {
let src = "let x = { 1 }; template {{expr}}";
let segs = parse_segments(src).unwrap();
assert_eq!(render(&segs), "let x = { 1 }; template <<expr>>");
}
}