use std::fmt::Write as _;
use maud::{Escaper, Markup, PreEscaped, Render};
use serde::Serialize;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum MountWhen {
#[default]
Load,
Idle,
Visible,
}
impl MountWhen {
const fn attr(self) -> Option<&'static str> {
match self {
Self::Load => None,
Self::Idle => Some("idle"),
Self::Visible => Some("visible"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum JsonKind {
Null,
Boolean,
Number,
String,
Array,
}
impl std::fmt::Display for JsonKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Null => "null",
Self::Boolean => "a boolean",
Self::Number => "a number",
Self::String => "a string",
Self::Array => "an array",
})
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PropsError {
#[error("island props do not serialize to JSON: {0}")]
Serialize(#[from] serde_json::Error),
#[error("island props must be a JSON object, not {0}")]
NotAnObject(JsonKind),
#[error("island props must not have a `__proto__` key")]
ProtoKey,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum AttrError {
#[error("`{0}` is not a valid attribute name")]
InvalidName(String),
#[error("the island attribute `{0}` is reserved; use the island builder method or the loader")]
Reserved(String),
}
fn check_attr_name(name: &str) -> Result<(), AttrError> {
let mut chars = name.chars();
let valid = chars.next().is_some_and(|c| c.is_ascii_alphabetic())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '.'));
if !valid {
return Err(AttrError::InvalidName(name.to_owned()));
}
let lower = name.to_ascii_lowercase();
if lower == "id"
|| lower == "class"
|| lower.starts_with("data-react-")
|| lower.starts_with("on")
{
return Err(AttrError::Reserved(name.to_owned()));
}
Ok(())
}
#[derive(Debug, Clone)]
#[must_use]
pub struct Island {
name: String,
props: Option<String>,
when: MountWhen,
fallback: Option<Markup>,
id: Option<String>,
class: Option<String>,
attrs: Vec<(String, String)>,
inline: bool,
}
impl Island {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
props: None,
when: MountWhen::Load,
fallback: None,
id: None,
class: None,
attrs: Vec::new(),
inline: false,
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
pub fn props<T: Serialize + ?Sized>(mut self, props: &T) -> Result<Self, PropsError> {
self.props = Some(props_json(props)?);
Ok(self)
}
pub const fn mount_when(mut self, when: MountWhen) -> Self {
self.when = when;
self
}
pub fn fallback(mut self, fallback: Markup) -> Self {
self.fallback = Some(fallback);
self
}
pub fn id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into());
self
}
pub fn class(mut self, class: impl Into<String>) -> Self {
self.class = Some(class.into());
self
}
pub fn attr(
mut self,
name: impl Into<String>,
value: impl Into<String>,
) -> Result<Self, AttrError> {
let name = name.into();
check_attr_name(&name)?;
let value = value.into();
match self.attrs.iter_mut().find(|(n, _)| *n == name) {
Some(slot) => slot.1 = value,
None => self.attrs.push((name, value)),
}
Ok(self)
}
pub const fn inline(mut self) -> Self {
self.inline = true;
self
}
}
pub(crate) fn props_json<T: Serialize + ?Sized>(props: &T) -> Result<String, PropsError> {
let json = serde_json::to_string(props)?;
let kind = match json.as_bytes().first() {
Some(b'{') => return check_proto_key(json),
Some(b'[') => JsonKind::Array,
Some(b'"') => JsonKind::String,
Some(b'n') => JsonKind::Null,
Some(b't' | b'f') => JsonKind::Boolean,
_ => JsonKind::Number,
};
Err(PropsError::NotAnObject(kind))
}
fn check_proto_key(json: String) -> Result<String, PropsError> {
let keys: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&json)?;
if keys.contains_key("__proto__") {
return Err(PropsError::ProtoKey);
}
Ok(json)
}
fn write_attr(html: &mut String, name: &str, value: &str) {
html.push(' ');
html.push_str(name);
html.push_str("=\"");
let _ = Escaper::new(html).write_str(value);
html.push('"');
}
impl Render for Island {
fn render(&self) -> Markup {
let tag = if self.inline { "span" } else { "div" };
let mut html = format!("<{tag}");
write_attr(&mut html, "data-react-island", &self.name);
let known = [
("data-react-props", self.props.as_deref()),
("data-react-mount", self.when.attr()),
("id", self.id.as_deref()),
("class", self.class.as_deref()),
];
for (name, value) in known {
if let Some(value) = value {
write_attr(&mut html, name, value);
}
}
for (name, value) in &self.attrs {
write_attr(&mut html, name, value);
}
html.push('>');
if let Some(fallback) = &self.fallback {
html.push_str(&fallback.0);
}
html.push_str("</");
html.push_str(tag);
html.push('>');
PreEscaped(html)
}
}