Skip to main content

autumn_plugin_react/
island.rs

1//! [`Island`]: the server side of one React island.
2
3use std::fmt::Write as _;
4
5use maud::{Escaper, Markup, PreEscaped, Render};
6use serde::Serialize;
7
8/// When the loader mounts an island.
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
10#[non_exhaustive]
11pub enum MountWhen {
12    /// Mount when the loader starts. This is the default.
13    #[default]
14    Load,
15    /// Mount when the browser is idle (`requestIdleCallback`).
16    Idle,
17    /// Mount when the island comes into the viewport.
18    Visible,
19}
20
21impl MountWhen {
22    /// The `data-react-mount` value. `None` for [`MountWhen::Load`].
23    const fn attr(self) -> Option<&'static str> {
24        match self {
25            Self::Load => None,
26            Self::Idle => Some("idle"),
27            Self::Visible => Some("visible"),
28        }
29    }
30}
31
32/// The kind of a JSON value that is not an object.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34#[non_exhaustive]
35pub enum JsonKind {
36    /// `null`.
37    Null,
38    /// `true` or `false`.
39    Boolean,
40    /// A number.
41    Number,
42    /// A string.
43    String,
44    /// An array.
45    Array,
46}
47
48impl std::fmt::Display for JsonKind {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.write_str(match self {
51            Self::Null => "null",
52            Self::Boolean => "a boolean",
53            Self::Number => "a number",
54            Self::String => "a string",
55            Self::Array => "an array",
56        })
57    }
58}
59
60/// The error from [`Island::props`].
61///
62/// It converts to `AutumnError` (status 500), so `?` works in a handler.
63#[derive(Debug, thiserror::Error)]
64#[non_exhaustive]
65pub enum PropsError {
66    /// The value does not serialize to JSON.
67    #[error("island props do not serialize to JSON: {0}")]
68    Serialize(#[from] serde_json::Error),
69    /// The value is JSON, but not a JSON object.
70    #[error("island props must be a JSON object, not {0}")]
71    NotAnObject(JsonKind),
72    /// The object has a `__proto__` key. React copies props with
73    /// `props[key] = value`, so this key would set the prototype.
74    #[error("island props must not have a `__proto__` key")]
75    ProtoKey,
76}
77
78/// The error from [`Island::attr`].
79///
80/// It converts to `AutumnError` (status 500), so `?` works in a handler.
81#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
82#[non_exhaustive]
83pub enum AttrError {
84    /// The name is empty, does not start with an ASCII letter, or has a
85    /// character other than `A-Z a-z 0-9 - _ : .`.
86    #[error("`{0}` is not a valid attribute name")]
87    InvalidName(String),
88    /// The name is `id`, `class`, `data-react-*` or an `on*` event handler.
89    #[error("the island attribute `{0}` is reserved; use the island builder method or the loader")]
90    Reserved(String),
91}
92
93/// Checks an extra attribute name for [`Island::attr`].
94fn check_attr_name(name: &str) -> Result<(), AttrError> {
95    let mut chars = name.chars();
96    let valid = chars.next().is_some_and(|c| c.is_ascii_alphabetic())
97        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.'));
98    if !valid {
99        return Err(AttrError::InvalidName(name.to_owned()));
100    }
101    let lower = name.to_ascii_lowercase();
102    if lower == "id"
103        || lower == "class"
104        || lower.starts_with("data-react-")
105        || lower.starts_with("on")
106    {
107        return Err(AttrError::Reserved(name.to_owned()));
108    }
109    Ok(())
110}
111
112/// One React island: an element that the loader mounts a component into.
113///
114/// It renders as:
115///
116/// ```html
117/// <div data-react-island="Counter" data-react-props="{...}"
118///      data-react-mount="visible" id="..." class="...">fallback</div>
119/// ```
120///
121/// The loader replaces the fallback with the component. Without JavaScript,
122/// or after an error, the fallback stays.
123///
124/// ```rust
125/// use autumn_plugin_react::{Island, MountWhen};
126/// use maud::{Render, html};
127///
128/// let island = Island::new("Counter")
129///     .props(&serde_json::json!({ "start": 3 }))?
130///     .mount_when(MountWhen::Visible)
131///     .fallback(html! { p { "Count: 3" } });
132/// let html = island.render().into_string();
133/// assert!(html.starts_with(r#"<div data-react-island="Counter""#));
134/// # Ok::<(), autumn_plugin_react::PropsError>(())
135/// ```
136///
137/// Props go in an HTML attribute. Keep them small. The component name, the
138/// `id` and the `class` are trusted input from your code.
139#[derive(Debug, Clone)]
140#[must_use]
141pub struct Island {
142    name: String,
143    props: Option<String>,
144    when: MountWhen,
145    fallback: Option<Markup>,
146    id: Option<String>,
147    class: Option<String>,
148    attrs: Vec<(String, String)>,
149    inline: bool,
150}
151
152impl Island {
153    /// Makes an island for the component that an app bundle registers as
154    /// `name`.
155    pub fn new(name: impl Into<String>) -> Self {
156        Self {
157            name: name.into(),
158            props: None,
159            when: MountWhen::Load,
160            fallback: None,
161            id: None,
162            class: None,
163            attrs: Vec::new(),
164            inline: false,
165        }
166    }
167
168    /// The component name.
169    #[must_use]
170    pub fn name(&self) -> &str {
171        &self.name
172    }
173
174    /// Sets the component props. The value must serialize to a JSON object.
175    ///
176    /// # Errors
177    ///
178    /// - [`PropsError::Serialize`] when `serde_json` cannot serialize the
179    ///   value (for example, a map with tuple keys).
180    /// - [`PropsError::NotAnObject`] when the JSON is not an object.
181    /// - [`PropsError::ProtoKey`] when the object has a `__proto__` key.
182    ///
183    /// `serde_json` writes a non-finite float (`NaN`, infinity) as `null`.
184    pub fn props<T: Serialize + ?Sized>(mut self, props: &T) -> Result<Self, PropsError> {
185        self.props = Some(props_json(props)?);
186        Ok(self)
187    }
188
189    /// Sets when the loader mounts the island. The default is
190    /// [`MountWhen::Load`].
191    pub const fn mount_when(mut self, when: MountWhen) -> Self {
192        self.when = when;
193        self
194    }
195
196    /// Sets the content to show before the mount, without JavaScript, and
197    /// after an error.
198    pub fn fallback(mut self, fallback: Markup) -> Self {
199        self.fallback = Some(fallback);
200        self
201    }
202
203    /// Sets the `id` attribute.
204    pub fn id(mut self, id: impl Into<String>) -> Self {
205        self.id = Some(id.into());
206        self
207    }
208
209    /// Sets the `class` attribute.
210    pub fn class(mut self, class: impl Into<String>) -> Self {
211        self.class = Some(class.into());
212        self
213    }
214
215    /// Adds an attribute, for example `role`, `aria-label` or
216    /// `hx-preserve`. A second call with the same name replaces the value.
217    ///
218    /// # Errors
219    ///
220    /// - [`AttrError::InvalidName`] for a name that is not a plain
221    ///   attribute name.
222    /// - [`AttrError::Reserved`] for `id`, `class` (use [`Island::id`] and
223    ///   [`Island::class`]), `data-react-*` (the loader owns them) and `on*`
224    ///   event handlers (the default CSP blocks them).
225    pub fn attr(
226        mut self,
227        name: impl Into<String>,
228        value: impl Into<String>,
229    ) -> Result<Self, AttrError> {
230        let name = name.into();
231        check_attr_name(&name)?;
232        let value = value.into();
233        match self.attrs.iter_mut().find(|(n, _)| *n == name) {
234            Some(slot) => slot.1 = value,
235            None => self.attrs.push((name, value)),
236        }
237        Ok(self)
238    }
239
240    /// Renders a `<span>`, not a `<div>`. Use it for an island inside a
241    /// `<p>`, a `<button>` or another element that holds only inline
242    /// content.
243    pub const fn inline(mut self) -> Self {
244        self.inline = true;
245        self
246    }
247}
248
249/// Serializes props to JSON text. The text must be a JSON object.
250pub(crate) fn props_json<T: Serialize + ?Sized>(props: &T) -> Result<String, PropsError> {
251    // Serialize one time and keep the field order. The first byte of JSON
252    // text gives its kind.
253    let json = serde_json::to_string(props)?;
254    let kind = match json.as_bytes().first() {
255        Some(b'{') => return check_proto_key(json),
256        Some(b'[') => JsonKind::Array,
257        Some(b'"') => JsonKind::String,
258        Some(b'n') => JsonKind::Null,
259        Some(b't' | b'f') => JsonKind::Boolean,
260        _ => JsonKind::Number,
261    };
262    Err(PropsError::NotAnObject(kind))
263}
264
265/// Refuses a JSON object with a top-level `__proto__` key.
266fn check_proto_key(json: String) -> Result<String, PropsError> {
267    // Read the keys only. Escaped keys (`\u005f_proto__`) decode too.
268    let keys: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&json)?;
269    if keys.contains_key("__proto__") {
270        return Err(PropsError::ProtoKey);
271    }
272    Ok(json)
273}
274
275/// Writes ` name="value"` with the value escaped.
276fn write_attr(html: &mut String, name: &str, value: &str) {
277    html.push(' ');
278    html.push_str(name);
279    html.push_str("=\"");
280    // Writing to a `String` cannot fail.
281    let _ = Escaper::new(html).write_str(value);
282    html.push('"');
283}
284
285impl Render for Island {
286    fn render(&self) -> Markup {
287        let tag = if self.inline { "span" } else { "div" };
288        let mut html = format!("<{tag}");
289        write_attr(&mut html, "data-react-island", &self.name);
290        let known = [
291            ("data-react-props", self.props.as_deref()),
292            ("data-react-mount", self.when.attr()),
293            ("id", self.id.as_deref()),
294            ("class", self.class.as_deref()),
295        ];
296        for (name, value) in known {
297            if let Some(value) = value {
298                write_attr(&mut html, name, value);
299            }
300        }
301        for (name, value) in &self.attrs {
302            write_attr(&mut html, name, value);
303        }
304        html.push('>');
305        if let Some(fallback) = &self.fallback {
306            html.push_str(&fallback.0);
307        }
308        html.push_str("</");
309        html.push_str(tag);
310        html.push('>');
311        PreEscaped(html)
312    }
313}