use maud::{Markup, 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),
}
#[derive(Debug, Clone)]
#[must_use]
pub struct Island {
name: String,
props: Option<String>,
when: MountWhen,
fallback: Option<Markup>,
id: Option<String>,
class: Option<String>,
}
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,
}
}
pub fn props<T: Serialize + ?Sized>(mut self, props: &T) -> Result<Self, PropsError> {
let json = serde_json::to_string(props)?;
let kind = match json.as_bytes().first() {
Some(b'{') => None,
Some(b'[') => Some(JsonKind::Array),
Some(b'"') => Some(JsonKind::String),
Some(b'n') => Some(JsonKind::Null),
Some(b't' | b'f') => Some(JsonKind::Boolean),
_ => Some(JsonKind::Number),
};
if let Some(kind) = kind {
return Err(PropsError::NotAnObject(kind));
}
self.props = Some(json);
Ok(self)
}
pub const fn mount_when(mut self, when: MountWhen) -> Self {
self.when = when;
self
}
pub fn fallback(mut self, content: Markup) -> Self {
self.fallback = Some(content);
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
}
}
impl Render for Island {
fn render(&self) -> Markup {
maud::html! {
div data-svelte-island=(self.name)
data-svelte-props=[self.props.as_deref()]
data-svelte-mount=[self.when.attr()]
id=[self.id.as_deref()]
class=[self.class.as_deref()]
{
@if let Some(fallback) = &self.fallback { (fallback) }
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use maud::html;
use proptest::prelude::*;
use std::collections::BTreeMap;
fn unescape(s: &str) -> String {
s.replace(""", "\"")
.replace("'", "'")
.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
}
fn attr<'a>(html: &'a str, attr: &str) -> Option<&'a str> {
let start = html.find(&format!(" {attr}=\""))? + attr.len() + 3;
let end = html[start..].find('"')? + start;
Some(&html[start..end])
}
#[test]
fn minimal_island_renders_only_the_name() {
let html = Island::new("Counter").render().into_string();
assert_eq!(html, r#"<div data-svelte-island="Counter"></div>"#);
}
#[test]
fn props_render_as_json_attribute() {
#[derive(Serialize)]
struct Props {
start: i32,
label: &'static str,
}
let html = Island::new("Counter")
.props(&Props {
start: 3,
label: "Hits",
})
.unwrap()
.render()
.into_string();
let raw = attr(&html, "data-svelte-props").expect("props attribute");
assert_eq!(unescape(raw), r#"{"start":3,"label":"Hits"}"#);
}
#[test]
fn props_escape_markup() {
let html = Island::new("X")
.props(&serde_json::json!({ "s": "\"><script>alert(1)</script>&'" }))
.unwrap()
.render()
.into_string();
assert!(!html.contains("<script"), "{html}");
let raw = attr(&html, "data-svelte-props").expect("props attribute");
let value: serde_json::Value = serde_json::from_str(&unescape(raw)).unwrap();
assert_eq!(value["s"], "\"><script>alert(1)</script>&'");
}
#[test]
fn non_object_props_are_refused() {
for (value, json_kind, kind) in [
(serde_json::json!(5), JsonKind::Number, "a number"),
(serde_json::json!(-1.5), JsonKind::Number, "a number"),
(serde_json::json!([1]), JsonKind::Array, "an array"),
(serde_json::json!("s"), JsonKind::String, "a string"),
(serde_json::json!(null), JsonKind::Null, "null"),
(serde_json::json!(true), JsonKind::Boolean, "a boolean"),
(serde_json::json!(false), JsonKind::Boolean, "a boolean"),
] {
let err = Island::new("X").props(&value).unwrap_err();
assert!(
matches!(err, PropsError::NotAnObject(k) if k == json_kind),
"{err}"
);
assert_eq!(json_kind.to_string(), kind);
assert_eq!(
err.to_string(),
format!("island props must be a JSON object, not {kind}")
);
}
}
#[test]
fn unserializable_props_are_refused() {
let mut map = BTreeMap::new();
map.insert(vec![1_u8], 1);
let err = Island::new("X").props(&map).unwrap_err();
assert!(matches!(err, PropsError::Serialize(_)), "{err}");
}
#[test]
fn props_error_converts_to_autumn_error() {
fn handler() -> autumn_web::AutumnResult<Island> {
Ok(Island::new("X").props(&5)?)
}
let err = handler().unwrap_err();
assert_eq!(
err.status(),
autumn_web::reexports::http::StatusCode::INTERNAL_SERVER_ERROR
);
}
#[test]
fn mount_strategy_renders_except_for_load() {
let render = |when| Island::new("X").mount_when(when).render().into_string();
assert!(!render(MountWhen::Load).contains("data-svelte-mount"));
assert!(render(MountWhen::Idle).contains(r#"data-svelte-mount="idle""#));
assert!(render(MountWhen::Visible).contains(r#"data-svelte-mount="visible""#));
assert_eq!(MountWhen::default(), MountWhen::Load);
}
#[test]
fn fallback_id_and_class_render() {
let html = Island::new("Counter")
.id("c1")
.class("card wide")
.fallback(html! { p { "Counter: 3" } })
.render()
.into_string();
assert_eq!(
html,
r#"<div data-svelte-island="Counter" id="c1" class="card wide"><p>Counter: 3</p></div>"#
);
}
#[test]
fn island_renders_inside_html_macro() {
let page = html! { main { (Island::new("A")) } }.into_string();
assert_eq!(page, r#"<main><div data-svelte-island="A"></div></main>"#);
}
#[test]
fn last_setter_wins() {
let html = Island::new("X")
.props(&serde_json::json!({"a": 1}))
.unwrap()
.props(&serde_json::json!({"b": 2}))
.unwrap()
.mount_when(MountWhen::Idle)
.mount_when(MountWhen::Visible)
.render()
.into_string();
assert_eq!(
unescape(attr(&html, "data-svelte-props").unwrap()),
r#"{"b":2}"#
);
assert!(html.contains(r#"data-svelte-mount="visible""#));
assert!(!html.contains("idle"));
}
proptest! {
#[test]
fn props_round_trip(map in proptest::collection::btree_map(".*", ".*", 0..6)) {
let html = Island::new("X").props(&map).unwrap().render().into_string();
let raw = attr(&html, "data-svelte-props").unwrap();
prop_assert!(!raw.contains('<') && !raw.contains('>'));
let back: BTreeMap<String, String> = serde_json::from_str(&unescape(raw)).unwrap();
prop_assert_eq!(back, map);
}
#[test]
fn attributes_never_break_out(name in ".*", id in ".*", class in ".*") {
let html = Island::new(name.clone()).id(id.clone()).class(class.clone())
.render().into_string();
prop_assert!(html.starts_with("<div data-svelte-island=\""));
prop_assert!(html.ends_with("\"></div>"));
prop_assert_eq!(html.matches('<').count(), 2);
prop_assert_eq!(unescape(attr(&html, "data-svelte-island").unwrap()), name);
prop_assert_eq!(unescape(attr(&html, "id").unwrap()), id);
prop_assert_eq!(unescape(attr(&html, "class").unwrap()), class);
}
}
}