use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use wasm_bindgen::JsCast;
use web_sys::{DocumentFragment, HtmlTemplateElement};
thread_local! {
static TEMPLATES: RefCell<HashMap<String, String>> = RefCell::new(HashMap::new());
static TEMPLATE_ELEMENTS: RefCell<HashMap<String, HtmlTemplateElement>> =
RefCell::new(HashMap::new());
}
pub fn register_template(name: impl Into<String>, html: impl Into<String>) {
TEMPLATES.with(|t| t.borrow_mut().insert(name.into(), html.into()));
}
pub fn template_for(name: &str) -> Option<Cow<'static, str>> {
if let Some(html) = crate::registry::active_component_vtable(name).and_then(|v| v.template_html)
{
return Some(Cow::Borrowed(html));
}
TEMPLATES.with(|t| t.borrow().get(name).cloned().map(Cow::Owned))
}
pub fn template_clone_for(name: &str) -> Option<DocumentFragment> {
let cached = TEMPLATE_ELEMENTS.with(|cache| cache.borrow().get(name).cloned());
let template_el = match cached {
Some(el) => el,
None => {
let html = template_for(name)?;
let doc = web_sys::window().and_then(|w| w.document())?;
let el = doc.create_element("template").ok()?;
let template_el = el.dyn_into::<HtmlTemplateElement>().ok()?;
template_el.set_inner_html(&html);
TEMPLATE_ELEMENTS.with(|cache| {
cache
.borrow_mut()
.insert(name.to_string(), template_el.clone());
});
template_el
}
};
let cloned_node = template_el.content().clone_node_with_deep(true).ok()?;
cloned_node.dyn_into::<DocumentFragment>().ok()
}
#[doc(hidden)]
pub fn clear_template_element_cache_for_test() {
TEMPLATE_ELEMENTS.with(|cache| cache.borrow_mut().clear());
}
pub fn is_registered(name: &str) -> bool {
if crate::registry::active_has_template(name) {
return true;
}
TEMPLATES.with(|t| t.borrow().contains_key(name))
}
pub fn registered_template_names() -> Vec<String> {
let mut names: Vec<String> = crate::registry::active_component_names()
.into_iter()
.filter(|name| crate::registry::active_has_template(name))
.map(str::to_string)
.collect();
let mut seen: HashSet<String> = names.iter().cloned().collect();
TEMPLATES.with(|t| {
for name in t.borrow().keys() {
if seen.insert(name.clone()) {
names.push(name.clone());
}
}
});
names
}
pub fn compile_template(raw: &str, name: &str, role: Option<(&str, &str)>) -> String {
let Some((tag, role_name)) = role else {
return inject_pp_data(raw, name);
};
let mut prefix = format!(r#"data-pine-role="{role_name}""#);
if tag == "button" && !root_placeholder_has_attr(raw, "type") {
prefix.push_str(r#" type="button""#);
}
let renamed = rewrite_root_placeholder(raw, tag, &prefix);
inject_pp_data(&renamed, name)
}
fn rewrite_root_placeholder(raw: &str, tag: &str, prefix_attrs: &str) -> String {
let attrs = prefix_attrs.trim();
let step1 = raw.replace("<root>", &format!("<{tag} {attrs}>"));
let step2 = step1.replace("<root ", &format!("<{tag} {attrs} "));
let step3 = step2.replace("<root/>", &format!("<{tag} {attrs}/>"));
step3.replace("</root>", &format!("</{tag}>"))
}
fn root_placeholder_has_attr(raw: &str, needle: &str) -> bool {
let Some(pos) = raw.find("<root") else {
return false;
};
let after = pos + "<root".len();
let boundary = raw.as_bytes().get(after).copied();
if !matches!(
boundary,
Some(b' ') | Some(b'>') | Some(b'/') | Some(b'\n') | Some(b'\t') | Some(b'\r')
) {
return false;
}
let bytes = raw.as_bytes();
let Some(close) = find_tag_end(bytes, pos) else {
return false;
};
let tag_slice = &raw[pos + 1..close];
for chunk in tag_slice.split_ascii_whitespace().skip(1) {
let name_end = chunk.find('=').unwrap_or(chunk.len());
if chunk[..name_end].eq_ignore_ascii_case(needle) {
return true;
}
}
false
}
pub fn inject_pp_data(raw: &str, name: &str) -> String {
let bytes = raw.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
while i < len && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= len {
break;
}
if bytes[i] != b'<' {
return raw.to_owned();
}
if i + 4 <= len && &bytes[i..i + 4] == b"<!--" {
if let Some(end) = find_seq(bytes, i + 4, b"-->") {
i = end + 3;
continue;
}
return raw.to_owned();
}
if i + 2 <= len && bytes[i + 1] == b'!' {
if let Some(end) = find_byte(bytes, i, b'>') {
i = end + 1;
continue;
}
return raw.to_owned();
}
if i + 2 <= len && bytes[i + 1] == b'?' {
if let Some(end) = find_seq(bytes, i + 2, b"?>") {
i = end + 2;
continue;
}
return raw.to_owned();
}
let Some(close) = find_tag_end(bytes, i) else {
return raw.to_owned();
};
let self_closing = close > 0 && bytes[close - 1] == b'/';
let insert_at = if self_closing { close - 1 } else { close };
let attr = format!(" data-pp-scope-id=\"{name}\"");
let mut out = String::with_capacity(raw.len() + attr.len());
out.push_str(&raw[..insert_at]);
if !out.ends_with(char::is_whitespace) {
out.push(' ');
}
out.push_str(attr.trim_start());
out.push_str(&raw[insert_at..]);
return out;
}
raw.to_owned()
}
fn find_byte(bytes: &[u8], start: usize, needle: u8) -> Option<usize> {
bytes[start..]
.iter()
.position(|&b| b == needle)
.map(|p| start + p)
}
fn find_seq(bytes: &[u8], start: usize, needle: &[u8]) -> Option<usize> {
if needle.is_empty() || start + needle.len() > bytes.len() {
return None;
}
(start..=bytes.len() - needle.len()).find(|&i| &bytes[i..i + needle.len()] == needle)
}
fn find_tag_end(bytes: &[u8], tag_start: usize) -> Option<usize> {
let len = bytes.len();
let mut i = tag_start + 1;
let mut quote: Option<u8> = None;
while i < len {
let b = bytes[i];
match quote {
Some(q) => {
if b == q {
quote = None;
}
}
None => match b {
b'"' | b'\'' => quote = Some(b),
b'>' => return Some(i),
_ => {}
},
}
i += 1;
}
None
}
#[cfg(test)]
mod tests {
use super::{compile_template, inject_pp_data};
#[test]
fn basic_root_gets_attr() {
let out = inject_pp_data("<div>hi</div>", "counter");
assert_eq!(out, r#"<div data-pp-scope-id="counter">hi</div>"#);
}
#[test]
fn preserves_existing_attrs() {
let out = inject_pp_data("<div class=\"x\" pp-text=\"label\">hi</div>", "counter");
assert_eq!(
out,
r#"<div class="x" pp-text="label" data-pp-scope-id="counter">hi</div>"#
);
}
#[test]
fn handles_self_closing_root() {
let out = inject_pp_data("<input type=\"text\" />", "foo");
assert_eq!(out, r#"<input type="text" data-pp-scope-id="foo"/>"#);
}
#[test]
fn skips_leading_comments() {
let out = inject_pp_data("<!-- hello --><div>x</div>", "x");
assert_eq!(out, r#"<!-- hello --><div data-pp-scope-id="x">x</div>"#);
}
#[test]
fn tolerates_gt_in_attr_value() {
let out = inject_pp_data("<div title=\"a > b\">x</div>", "n");
assert_eq!(out, r#"<div title="a > b" data-pp-scope-id="n">x</div>"#);
}
#[test]
fn compile_template_no_role_matches_inject_pp_data() {
let out = compile_template("<div>hi</div>", "c", None);
assert_eq!(out, r#"<div data-pp-scope-id="c">hi</div>"#);
}
#[test]
fn role_visual_rewrites_root_to_span() {
let out = compile_template(
"<root class=\"pine-avatar-root\"><slot></slot></root>",
"pine-avatar-root",
Some(("span", "visual")),
);
assert_eq!(
out,
r#"<span data-pine-role="visual" class="pine-avatar-root" data-pp-scope-id="pine-avatar-root"><slot></slot></span>"#
);
}
#[test]
fn role_interactive_injects_type_button() {
let out = compile_template(
"<root class=\"pine-switch\"><slot/></root>",
"pine-switch",
Some(("button", "interactive")),
);
assert!(out.starts_with(
r#"<button data-pine-role="interactive" type="button" class="pine-switch""#
));
assert!(out.ends_with("</button>"));
}
#[test]
fn role_interactive_respects_existing_type() {
let out = compile_template(
"<root type=\"submit\" class=\"x\"><slot/></root>",
"c",
Some(("button", "interactive")),
);
assert_eq!(out.matches("type=").count(), 1);
assert!(out.contains(r#"type="submit""#));
}
#[test]
fn role_panel_rewrites_to_div() {
let out = compile_template("<root><slot/></root>", "p", Some(("div", "panel")));
assert_eq!(
out,
r#"<div data-pine-role="panel" data-pp-scope-id="p"><slot/></div>"#
);
}
#[test]
fn role_with_self_closing_root() {
let out = compile_template(
"<root :src=\"src\"/>",
"pine-avatar-image",
Some(("img", "media")),
);
assert!(out.contains(r#"data-pine-role="media""#));
assert!(out.contains(r#"data-pp-scope-id="pine-avatar-image""#));
assert!(out.ends_with("/>"));
}
}