1use std::fmt::Write as _;
4
5use maud::{Escaper, Markup, PreEscaped, Render};
6use serde::Serialize;
7
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
10#[non_exhaustive]
11pub enum MountWhen {
12 #[default]
14 Load,
15 Idle,
17 Visible,
19}
20
21impl MountWhen {
22 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34#[non_exhaustive]
35pub enum JsonKind {
36 Null,
38 Boolean,
40 Number,
42 String,
44 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#[derive(Debug, thiserror::Error)]
64#[non_exhaustive]
65pub enum PropsError {
66 #[error("island props do not serialize to JSON: {0}")]
68 Serialize(#[from] serde_json::Error),
69 #[error("island props must be a JSON object, not {0}")]
71 NotAnObject(JsonKind),
72 #[error("island props must not have a `__proto__` key")]
75 ProtoKey,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
82#[non_exhaustive]
83pub enum AttrError {
84 #[error("`{0}` is not a valid attribute name")]
87 InvalidName(String),
88 #[error("the island attribute `{0}` is reserved; use the island builder method or the loader")]
90 Reserved(String),
91}
92
93fn 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#[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 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 #[must_use]
170 pub fn name(&self) -> &str {
171 &self.name
172 }
173
174 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 pub const fn mount_when(mut self, when: MountWhen) -> Self {
192 self.when = when;
193 self
194 }
195
196 pub fn fallback(mut self, fallback: Markup) -> Self {
199 self.fallback = Some(fallback);
200 self
201 }
202
203 pub fn id(mut self, id: impl Into<String>) -> Self {
205 self.id = Some(id.into());
206 self
207 }
208
209 pub fn class(mut self, class: impl Into<String>) -> Self {
211 self.class = Some(class.into());
212 self
213 }
214
215 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 pub const fn inline(mut self) -> Self {
244 self.inline = true;
245 self
246 }
247}
248
249pub(crate) fn props_json<T: Serialize + ?Sized>(props: &T) -> Result<String, PropsError> {
251 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
265fn check_proto_key(json: String) -> Result<String, PropsError> {
267 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
275fn write_attr(html: &mut String, name: &str, value: &str) {
277 html.push(' ');
278 html.push_str(name);
279 html.push_str("=\"");
280 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}