lib.rsannotatedlib.rssource201 lines · 6.5 KB · raw
1//! Datastar's wire, once: the events a server sends, the signals it reads, and the script a page loads.
2//!
3//! No I/O and no runtime. An event is built here and written out as text ([`PatchElements::sse`],
4//! [`PatchSignals::sse`]); an adapter for a server (the Worker's, axum's) puts that text on a response.
5//! Datastar's own types are not used, so nothing here moves when that crate does.
6
7use serde::de::DeserializeOwned;
8use serde::Serialize;
9
10/// The script a page loads. Datastar 1.0.4, the same bytes in every property.
11pub const SCRIPT: &str = include_str!("../datastar.js");
12
13/// The query parameter a GET request carries its signals in, as JSON.
14pub const QUERY_PARAMETER: &str = "datastar";
15
16/// The header every Datastar request carries, with the value `true`.
17pub const REQUEST_HEADER: &str = "datastar-request";
18
19/// The `content-type` of a stream of events.
20pub const EVENT_STREAM: &str = "text/event-stream";
21
22/// How patched elements meet the page.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum Mode {
25    /// Morph the element with the same id (Datastar's default).
26    Outer,
27    /// Morph the inside of the selected element.
28    Inner,
29    Replace,
30    Prepend,
31    Append,
32    Before,
33    After,
34    Remove,
35}
36
37impl Mode {
38    fn word(self) -> &'static str {
39        match self {
40            Mode::Outer => "outer",
41            Mode::Inner => "inner",
42            Mode::Replace => "replace",
43            Mode::Prepend => "prepend",
44            Mode::Append => "append",
45            Mode::Before => "before",
46            Mode::After => "after",
47            Mode::Remove => "remove",
48        }
49    }
50}
51
52/// Elements to put into the page.
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct PatchElements {
55    html: String,
56    selector: Option<String>,
57    mode: Option<Mode>,
58}
59
60impl PatchElements {
61    /// Elements that carry ids, which Datastar morphs in place.
62    pub fn new(html: impl Into<String>) -> PatchElements {
63        PatchElements { html: html.into(), selector: None, mode: None }
64    }
65
66    /// Takes away what the selector matches.
67    pub fn remove(selector: impl Into<String>) -> PatchElements {
68        PatchElements { html: String::new(), selector: Some(selector.into()), mode: Some(Mode::Remove) }
69    }
70
71    pub fn selector(mut self, selector: impl Into<String>) -> PatchElements {
72        self.selector = Some(selector.into());
73        self
74    }
75
76    pub fn mode(mut self, mode: Mode) -> PatchElements {
77        self.mode = Some(mode);
78        self
79    }
80
81    /// The event as stream text. Each line of the markup is its own `elements` line, so a newline in
82    /// the markup (inside a `<pre>`) reaches the page as it was written.
83    pub fn sse(&self) -> String {
84        let mut out = String::from("event: datastar-patch-elements\n");
85        if let Some(selector) = &self.selector {
86            out.push_str(&format!("data: selector {}\n", one_line(selector)));
87        }
88        if let Some(mode) = self.mode {
89            out.push_str(&format!("data: mode {}\n", mode.word()));
90        }
91        for line in self.html.lines() {
92            out.push_str("data: elements ");
93            out.push_str(line);
94            out.push('\n');
95        }
96        out.push('\n');
97        out
98    }
99}
100
101/// Signals to merge into the page's.
102#[derive(Clone, Debug, PartialEq, Eq)]
103pub struct PatchSignals {
104    json: String,
105    only_if_missing: bool,
106}
107
108impl PatchSignals {
109    /// Signals from anything that serializes to a JSON object. A `null` member removes that signal.
110    pub fn of(signals: &impl Serialize) -> Result<PatchSignals, serde_json::Error> {
111        Ok(PatchSignals { json: serde_json::to_string(signals)?, only_if_missing: false })
112    }
113
114    /// Sets only the signals the page does not have yet.
115    pub fn only_if_missing(mut self) -> PatchSignals {
116        self.only_if_missing = true;
117        self
118    }
119
120    pub fn sse(&self) -> String {
121        let mut out = String::from("event: datastar-patch-signals\n");
122        if self.only_if_missing {
123            out.push_str("data: onlyIfMissing true\n");
124        }
125        out.push_str(&format!("data: signals {}\n\n", self.json));
126        out
127    }
128}
129
130/// The signals of a request: the `datastar` query parameter's value (already percent-decoded) for a
131/// GET, the body for anything else. The same JSON either way.
132pub fn signals<T: DeserializeOwned>(json: &str) -> Result<T, serde_json::Error> {
133    serde_json::from_str(json)
134}
135
136/// What a page asks of a server with no signals: an empty object, so a field with a default reads.
137pub fn no_signals() -> &'static str {
138    "{}"
139}
140
141/// A selector or a signal name is a single line; a line break in one would end the `data` line.
142fn one_line(text: &str) -> String {
143    text.replace(['\n', '\r'], " ")
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use serde::Deserialize;
150
151    #[test]
152    fn a_patch_is_one_data_line_per_line_of_markup() {
153        let sse = PatchElements::new("<pre id=\"a\">x\n  y</pre>").sse();
154        assert_eq!(sse, "event: datastar-patch-elements\ndata: elements <pre id=\"a\">x\ndata: elements   y</pre>\n\n");
155    }
156
157    #[test]
158    fn selector_and_mode_come_before_the_markup() {
159        let sse = PatchElements::new("<li>1</li>").selector("#list").mode(Mode::Append).sse();
160        assert_eq!(
161            sse,
162            "event: datastar-patch-elements\ndata: selector #list\ndata: mode append\ndata: elements <li>1</li>\n\n"
163        );
164    }
165
166    #[test]
167    fn removing_names_only_the_selector() {
168        assert_eq!(
169            PatchElements::remove("#gone").sse(),
170            "event: datastar-patch-elements\ndata: selector #gone\ndata: mode remove\n\n"
171        );
172    }
173
174    #[test]
175    fn a_selector_cannot_end_its_own_line() {
176        assert!(!PatchElements::new("<p></p>").selector("#a\ndata: elements <script>").sse().contains("\ndata: elements <script>"));
177    }
178
179    #[test]
180    fn signals_are_one_json_line() {
181        let sse = PatchSignals::of(&serde_json::json!({ "n": 1, "s": "a\nb" })).unwrap().only_if_missing().sse();
182        assert_eq!(sse, "event: datastar-patch-signals\ndata: onlyIfMissing true\ndata: signals {\"n\":1,\"s\":\"a\\nb\"}\n\n");
183    }
184
185    #[test]
186    fn signals_read_with_defaults() {
187        #[derive(Deserialize, Default, PartialEq, Debug)]
188        #[serde(default)]
189        struct Page {
190            name: String,
191            count: u32,
192        }
193        assert_eq!(signals::<Page>(r#"{"name":"a"}"#).unwrap(), Page { name: "a".into(), count: 0 });
194        assert_eq!(signals::<Page>(no_signals()).unwrap(), Page::default());
195    }
196
197    #[test]
198    fn the_script_is_datastar_one_zero_four() {
199        assert!(SCRIPT.contains("1.0.4"));
200    }
201}