Skip to main content

topcoat_runtime/
js.rs

1use std::borrow::Cow;
2
3use serde::Serialize;
4use topcoat_core::context::Cx;
5use topcoat_view::{AttributeValueViewParts, PartsWriter};
6
7/// JavaScript source and captured values for a runtime expression.
8///
9/// Captured Rust values are serialized into the source. Embedding it in a
10/// view escapes it for the surrounding HTML context.
11#[derive(Debug, Clone)]
12pub struct Js {
13    parts: Vec<JsPart>,
14}
15
16#[derive(Debug, Clone)]
17enum JsPart {
18    /// Source text, escaped for the position it renders in.
19    Source(Cow<'static, str>),
20    /// Trusted scaffolding written verbatim, like the `const [..] = [..]`
21    /// wrapper around captured values.
22    Raw(&'static str),
23    /// A captured value as JSON, hydrated on the client.
24    Surrogate(String),
25}
26
27impl Js {
28    /// Source without captured values.
29    #[must_use]
30    pub fn source(js: impl Into<Cow<'static, str>>) -> Self {
31        Self {
32            parts: vec![JsPart::Source(js.into())],
33        }
34    }
35
36    /// Starts source that interleaves captured values.
37    #[must_use]
38    pub fn builder() -> JsBuilder {
39        JsBuilder { parts: Vec::new() }
40    }
41
42    /// Returns executable JavaScript, including serialized captured values.
43    ///
44    /// The source expects the runtime context to be available as `cx`. It is
45    /// not escaped for embedding in HTML.
46    #[must_use]
47    pub fn to_source(&self) -> String {
48        let mut source = String::new();
49        for part in &self.parts {
50            match part {
51                JsPart::Source(js) => source.push_str(js),
52                JsPart::Raw(js) => source.push_str(js),
53                JsPart::Surrogate(json) => {
54                    source.push_str("cx.hydrate(");
55                    source.push_str(json);
56                    source.push(')');
57                }
58            }
59        }
60        source
61    }
62
63    /// Writes the source through `parts`, sealed for the writer's current
64    /// context: a marker comment's body or a double-quoted attribute value.
65    pub(crate) fn write(&self, parts: &mut PartsWriter<'_>) {
66        for part in &self.parts {
67            match part {
68                JsPart::Source(Cow::Borrowed(js)) => {
69                    parts.push_static_str(js);
70                }
71                JsPart::Source(Cow::Owned(js)) => {
72                    parts.push_str(js);
73                }
74                JsPart::Raw(js) => {
75                    parts.push_static_str_unescaped(js);
76                }
77                JsPart::Surrogate(json) => {
78                    parts
79                        .push_promoted_str_unescaped(&"cx.hydrate(")
80                        .push_str(json)
81                        .push_promoted_str_unescaped(&")");
82                }
83            }
84        }
85    }
86}
87
88/// The source as the value of a `data-topcoat-*` attribute, like an event
89/// handler or a bind expression.
90impl AttributeValueViewParts for Js {
91    #[inline]
92    fn attribute_present(&self) -> bool {
93        true
94    }
95
96    #[inline]
97    fn into_view_parts(self, _cx: &Cx, parts: &mut PartsWriter<'_>) {
98        self.write(parts);
99    }
100}
101
102/// Builds a [`Js`] from source and captured values, in order.
103#[derive(Debug)]
104pub struct JsBuilder {
105    parts: Vec<JsPart>,
106}
107
108impl JsBuilder {
109    /// Appends an expression in parentheses, preserving its captured values.
110    #[must_use]
111    pub fn expression<T>(mut self, expression: &crate::Expr<T>) -> Self {
112        self.parts.push(JsPart::Raw("("));
113        self.parts.extend(expression.js.parts.iter().cloned());
114        self.parts.push(JsPart::Raw(")"));
115        self
116    }
117
118    /// Appends source text.
119    #[must_use]
120    pub fn source(mut self, js: impl Into<Cow<'static, str>>) -> Self {
121        self.parts.push(JsPart::Source(js.into()));
122        self
123    }
124
125    /// Appends trusted scaffolding, written verbatim.
126    #[must_use]
127    pub fn raw(mut self, js: &'static str) -> Self {
128        self.parts.push(JsPart::Raw(js));
129        self
130    }
131
132    /// Appends a captured value, serialized now so the Rust expression can
133    /// consume it afterwards.
134    ///
135    /// # Panics
136    ///
137    /// Panics if the value fails to serialize.
138    #[must_use]
139    pub fn surrogate(mut self, value: &(impl Serialize + ?Sized)) -> Self {
140        let json = serde_json::to_string(value).expect("failed to serialize surrogate value");
141        self.parts.push(JsPart::Surrogate(json));
142        self
143    }
144
145    /// Finishes the builder into a [`Js`].
146    #[must_use]
147    pub fn build(self) -> Js {
148        Js { parts: self.parts }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn executable_source_preserves_javascript_and_capture_escaping() {
158        let js = Js::builder()
159            .raw("(() => { const value = ")
160            .surrogate(&"\"<>&\n")
161            .source("; return value; })()")
162            .build();
163
164        assert_eq!(
165            js.to_source(),
166            r#"(() => { const value = cx.hydrate("\"<>&\n"); return value; })()"#,
167        );
168    }
169}