pocopine-core 0.1.0

Client-side reactive runtime for pocopine — a Rust/WASM port of Alpine.js.
Documentation
//! Component template registry.
//!
//! The `#[component]` macro emits a call to [`register_template`] with the
//! component's compiled HTML. The macro pipes the raw `.poco` contents
//! through [`inject_pp_data`] so the mount can recognise the template
//! root by its `data-pp-scope-id` attribute without authors having to type one.

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());
    /// RFC-058 Phase 6.4 — per-template `<template>` element
    /// cache. Lazily populated on first mount: the registered
    /// HTML string is parsed once into an `HTMLTemplateElement`
    /// (browser HTML parser, C++ fast path), then every
    /// subsequent mount clones the `.content` `DocumentFragment`
    /// via `cloneNode(true)` instead of re-parsing the HTML
    /// string. Same shape Solid/Lit use; mount throughput goes
    /// up, bundle size unchanged.
    static TEMPLATE_ELEMENTS: RefCell<HashMap<String, HtmlTemplateElement>> =
        RefCell::new(HashMap::new());
}

/// Register a component's rewritten template under its runtime name.
pub fn register_template(name: impl Into<String>, html: impl Into<String>) {
    TEMPLATES.with(|t| t.borrow_mut().insert(name.into(), html.into()));
}

/// Fetch a registered template. Static app registries return a
/// borrowed template; thread-local fallback registrations return
/// an owned clone so callers never hold the registry borrow while
/// touching the DOM.
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))
}

/// RFC-058 Phase 6.4 — clone a registered template's content into
/// a fresh `DocumentFragment` via `HTMLTemplateElement.content`
/// + `cloneNode(true)`.
///
/// Lazily parses the HTML string on first call, caches the
/// `HTMLTemplateElement` so subsequent mounts skip the re-parse.
///
/// Returns `None` when the template isn't registered, the
/// document isn't available (non-browser context), or the
/// browser refuses to clone the content. Callers fall back to
/// the legacy [`template_for`] + `set_inner_html` path on
/// `None`.
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))
}

/// Snapshot every registered component template name. Returned in
/// HashMap iteration order — callers needing stability sort. Used
/// by the compiled-mount entry + the adopted-DOM bridge to drive
/// `Element::query_selector_all` against every known custom-element
/// tag, including components whose templates carry no directives
/// (those don't show up in `templates_plan::registered_template_tags`
/// because that registry only holds tags with at least one
/// plan-eligible entry).
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
}

/// Full template compilation entry-point for the macro.
///
/// - `role = None` → just injects `data-pp-scope-id` (the classic path).
/// - `role = Some((tag, role_name))` → treats the template root as a
///   `<root>` placeholder (see [RFC-033](../../rfcs/rfc-033-primitive-roles.md)).
///   Rewrites `<root>` / `</root>` to `<tag>` / `</tag>`, splicing
///   `data-pine-role="<role_name>"` into the rewritten opening tag
///   (so the role attribute lands on the primitive's real root even
///   when it's wrapped in `<template pp-if="...">`). For `tag == "button"`
///   and no existing `type` attribute, also injects `type="button"`.
///   Finally runs [`inject_pp_data`] so `data-pp-scope-id` lands on the outermost
///   opening tag as usual.
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)
}

/// Rewrite `<root>` / `<root ...>` / `<root/>` to `<tag ... prefix_attrs>`
/// and `</root>` to `</tag>`. `root` isn't a real HTML element, so
/// every occurrence in a `.poco` file is unambiguously the placeholder.
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}>"))
}

/// True when the `<root>` placeholder tag has an attribute named
/// `needle` (case-insensitive). Only looks inside the placeholder's
/// own opening tag — ignores siblings and children.
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
}

/// Insert `data-pp-scope-id="<name>"` into the first element's opening tag of
/// `raw`. The caller guarantees the template has a real element root;
/// comments, doctypes, and leading whitespace are skipped.
///
/// The parser is deliberately minimal — enough for plain HTML-with-
/// directives as authored in `.poco` files. A full HTML parser is
/// overkill for a compile-time rewrite of author-controlled input.
pub fn inject_pp_data(raw: &str, name: &str) -> String {
    // Walk to the first opening tag, skipping comments / doctypes.
    let bytes = raw.as_bytes();
    let len = bytes.len();
    let mut i = 0;
    while i < len {
        // Skip whitespace between chunks.
        while i < len && bytes[i].is_ascii_whitespace() {
            i += 1;
        }
        if i >= len {
            break;
        }
        if bytes[i] != b'<' {
            // Stray text before the root — emit as-is and give up on
            // rewriting. The mount will fail gracefully when it can't
            // find `data-pp-scope-id`.
            return raw.to_owned();
        }
        // `<!--` comment
        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();
        }
        // `<!DOCTYPE ...>`
        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();
        }
        // `<?xml ... ?>`
        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();
        }
        // A real opening tag. Find its end (`>` or `/>`), respecting
        // attribute-value quoting.
        let Some(close) = find_tag_end(bytes, i) else {
            return raw.to_owned();
        };
        // Splice ` data-pp-scope-id="<name>"` before the closing
        // char(s). If the tag is self-closing (`<foo />`), keep
        // the `/>`.
        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]);
        // Ensure exactly one space before the attribute.
        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)
}

/// Find the index of `>` (or `/` in `/>`) that closes the opening tag
/// starting at `tag_start` (a `<`). Respects attribute value quoting so
/// a `>` inside `title="a > b"` isn't mistaken for the end.
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>"#);
    }

    // ── compile_template + role rewriting ─────────────────────────

    #[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")),
        );
        // Only one `type=` should appear — the original submit wins.
        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() {
        // Self-closing placeholder: `<root/>` → `<img .../>`.
        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("/>"));
    }
}