shelly-liveview 0.6.0

Core runtime primitives for Shelly LiveView.
Documentation
use std::fmt;

/// A server-rendered HTML fragment.
///
/// Shelly v0 treats HTML as an opaque fragment. Later milestones can replace
/// this with a typed template representation that separates static and dynamic
/// segments for diffing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Html {
    value: String,
    template: Option<TemplateSnapshot>,
}

impl Html {
    /// Construct an HTML fragment from a string-like value.
    pub fn new(value: impl Into<String>) -> Self {
        Self {
            value: value.into(),
            template: None,
        }
    }

    /// Borrow the fragment as a string slice.
    pub fn as_str(&self) -> &str {
        &self.value
    }

    /// Consume the fragment and return the underlying string.
    pub fn into_string(self) -> String {
        self.value
    }

    /// Borrow template metadata when this HTML came from `Template`.
    pub fn template(&self) -> Option<&TemplateSnapshot> {
        self.template.as_ref()
    }
}

impl From<String> for Html {
    fn from(value: String) -> Self {
        Self::new(value)
    }
}

impl From<&str> for Html {
    fn from(value: &str) -> Self {
        Self::new(value)
    }
}

impl fmt::Display for Html {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.value)
    }
}

/// Render metadata retained from a segmented template.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TemplateSnapshot {
    static_segments: Vec<&'static str>,
    dynamic_segments: Vec<String>,
}

impl TemplateSnapshot {
    /// Borrow trusted static template segments.
    pub fn static_segments(&self) -> &[&'static str] {
        &self.static_segments
    }

    /// Borrow escaped dynamic template segments.
    pub fn dynamic_segments(&self) -> &[String] {
        &self.dynamic_segments
    }

    pub(crate) fn compatible_with(&self, other: &Self) -> bool {
        self.static_segments == other.static_segments
            && self.dynamic_segments.len() == other.dynamic_segments.len()
    }
}

/// A rendered template split into static and dynamic segments.
///
/// Static segments are trusted template literals. Dynamic segments are escaped
/// before rendering, which gives the v1 template macro a safe default while
/// keeping v0 `Html` as the final transport shape.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Template {
    static_segments: Vec<&'static str>,
    dynamic_segments: Vec<String>,
}

impl Template {
    /// Construct a segmented template.
    pub fn new(static_segments: Vec<&'static str>, dynamic_segments: Vec<String>) -> Self {
        Self {
            static_segments,
            dynamic_segments,
        }
    }

    /// Borrow trusted static template segments.
    pub fn static_segments(&self) -> &[&'static str] {
        &self.static_segments
    }

    /// Borrow escaped dynamic template segments.
    pub fn dynamic_segments(&self) -> &[String] {
        &self.dynamic_segments
    }

    /// Render this template into opaque v0 HTML.
    pub fn render(&self) -> Html {
        let mut out = String::new();
        for (index, segment) in self.static_segments.iter().enumerate() {
            out.push_str(segment);
            if let Some(dynamic) = self.dynamic_segments.get(index) {
                out.push_str(r#"<span data-shelly-slot=""#);
                out.push_str(&index.to_string());
                out.push_str(r#"">"#);
                out.push_str(dynamic);
                out.push_str("</span>");
            }
        }

        Html {
            value: out,
            template: Some(TemplateSnapshot {
                static_segments: self.static_segments.clone(),
                dynamic_segments: self.dynamic_segments.clone(),
            }),
        }
    }

    /// Render this template into a raw HTML string.
    pub fn render_string(&self) -> String {
        self.render().into_string()
    }
}

impl From<Template> for Html {
    fn from(value: Template) -> Self {
        value.render()
    }
}

/// Escape text for inclusion in HTML text or attribute values.
///
/// Shelly v0 keeps rendered HTML opaque, so application code that interpolates
/// untrusted values into `Html` must escape those values before formatting.
pub fn escape_html(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}

#[cfg(test)]
mod tests {
    use super::{escape_html, Html, Template};

    #[test]
    fn html_wraps_fragment() {
        let html = Html::new("<p>Hello</p>");
        assert_eq!(html.as_str(), "<p>Hello</p>");
        assert_eq!(html.into_string(), "<p>Hello</p>");
    }

    #[test]
    fn escape_html_escapes_text_and_attribute_delimiters() {
        assert_eq!(
            escape_html(r#"<script title="x">Tom & 'Ada'</script>"#),
            "&lt;script title=&quot;x&quot;&gt;Tom &amp; &#39;Ada&#39;&lt;/script&gt;"
        );
    }

    #[test]
    fn template_renders_static_and_dynamic_segments() {
        let template = Template::new(vec!["<p>Hello ", "</p>"], vec![escape_html("<Ada & Bob>")]);

        assert_eq!(template.static_segments(), &["<p>Hello ", "</p>"]);
        assert_eq!(template.dynamic_segments(), &["&lt;Ada &amp; Bob&gt;"]);
        assert_eq!(
            template.render_string(),
            r#"<p>Hello <span data-shelly-slot="0">&lt;Ada &amp; Bob&gt;</span></p>"#
        );
    }

    #[test]
    fn html_from_and_template_snapshot_helpers_cover_metadata_paths() {
        let from_string = Html::from(String::from("<p>a</p>"));
        let from_str = Html::from("<p>b</p>");
        assert_eq!(from_string.as_str(), "<p>a</p>");
        assert_eq!(from_str.as_str(), "<p>b</p>");

        let template_a = Template::new(vec!["<p>", "</p>"], vec!["1".to_string()]);
        let template_b = Template::new(vec!["<p>", "</p>"], vec!["2".to_string()]);
        let html_a = template_a.render();
        let html_b = template_b.render();
        let snap_a = html_a.template().expect("template metadata");
        let snap_b = html_b.template().expect("template metadata");

        assert_eq!(snap_a.static_segments(), &["<p>", "</p>"]);
        assert_eq!(snap_a.dynamic_segments(), &["1".to_string()]);
        assert!(snap_a.compatible_with(snap_b));
    }
}