use serde::Serialize;
pub struct Html<'p, P, F = fn(&mut String, &P)> {
props: &'p P,
render: F,
island: Option<&'static str>,
}
impl<'p, P: Serialize, F: Fn(&mut String, &P)> Html<'p, P, F> {
#[doc(hidden)]
pub fn markup(props: &'p P, render: F) -> Self {
Html {
props,
render,
island: None,
}
}
#[doc(hidden)]
pub fn island(name: &'static str, props: &'p P, render: F) -> Self {
Html {
props,
render,
island: Some(name),
}
}
pub fn render_to(&self, buf: &mut String) {
match self.island {
None => (self.render)(buf, self.props),
Some(name) => island_into(buf, name, self.props, &self.render),
}
}
pub fn into_string(self) -> String {
let mut out = String::new();
self.render_to(&mut out);
out
}
}
#[cfg(feature = "maud")]
impl<P: Serialize, F: Fn(&mut String, &P)> maud::Render for Html<'_, P, F> {
fn render_to(&self, buf: &mut String) {
Html::render_to(self, buf);
}
}
#[derive(Clone, Copy)]
pub struct Slot<'s> {
body: Body<'s>,
}
#[derive(Clone, Copy)]
enum Body<'s> {
Content(&'s dyn Fn(&mut String)),
Markup(&'s dyn Fn(&mut String) -> bool),
}
impl<'s> Slot<'s> {
pub fn new(render: &'s dyn Fn(&mut String)) -> Self {
Slot {
body: Body::Content(render),
}
}
#[doc(hidden)]
pub fn markup(render: &'s dyn Fn(&mut String) -> bool) -> Self {
Slot {
body: Body::Markup(render),
}
}
pub fn render_to(&self, out: &mut String) {
match self.body {
Body::Content(f) => f(out),
Body::Markup(f) => {
f(out);
}
}
}
}
const HOLE: &str = "<fv-hole>";
fn write_hole(out: &mut String) {
out.push_str(HOLE);
}
pub fn hole() -> Slot<'static> {
Slot::new(&write_hole)
}
pub fn split_holes(rendered: &str) -> Vec<&str> {
rendered.split(HOLE).collect()
}
pub fn slot_into(
out: &mut String,
slot: Option<Slot<'_>>,
fallback: Option<&mut dyn FnMut(&mut String)>,
) -> bool {
out.push_str("<!--[-->");
let filled = match slot.map(|s| s.body) {
Some(Body::Content(f)) => {
f(out);
true
}
Some(Body::Markup(f)) => {
let start = out.len();
let filled = f(out);
if !filled {
out.truncate(start);
if let Some(fallback) = fallback {
fallback(out);
}
}
filled
}
None => {
if let Some(fallback) = fallback {
fallback(out);
}
false
}
};
out.push_str("<!--]-->");
filled
}
pub fn scoped_slot_into<P: ?Sized, F: Fn(&mut String, &P) -> bool + ?Sized>(
out: &mut String,
slot: Option<&F>,
props: &P,
fallback: Option<&mut dyn FnMut(&mut String)>,
) -> bool {
out.push_str("<!--[-->");
let start = out.len();
let filled = slot.is_some_and(|f| f(out, props));
if !filled {
out.truncate(start);
if let Some(fallback) = fallback {
fallback(out);
}
}
out.push_str("<!--]-->");
filled
}
pub trait TrustedHtml {
fn trusted_html(&self) -> &str;
}
pub fn trusted_into(out: &mut String, html: &impl TrustedHtml) {
out.push_str(html.trusted_html());
}
pub fn escape_into(out: &mut String, s: &str) {
let special = s.bytes().fold(false, |found, b| {
found | matches!(b, b'"' | b'&' | b'\'' | b'<' | b'>')
});
if !special {
out.push_str(s);
return;
}
let mut last = 0;
for (i, b) in s.bytes().enumerate() {
let rep = match b {
b'"' => """,
b'&' => "&",
b'\'' => "'",
b'<' => "<",
b'>' => ">",
_ => continue,
};
out.push_str(&s[last..i]);
out.push_str(rep);
last = i + 1;
}
out.push_str(&s[last..]);
}
pub fn push_int(out: &mut String, n: i64) {
use std::fmt::Write;
let _ = write!(out, "{n}");
}
pub fn js_length(s: &str) -> i64 {
if s.is_ascii() {
return s.len() as i64;
}
s.chars().map(char::len_utf16).sum::<usize>() as i64
}
pub fn js_trim(s: &str) -> &str {
s.trim_matches(is_js_space)
}
pub fn js_trim_start(s: &str) -> &str {
s.trim_start_matches(is_js_space)
}
pub fn js_trim_end(s: &str) -> &str {
s.trim_end_matches(is_js_space)
}
fn is_js_space(c: char) -> bool {
matches!(
c,
'\u{9}' | '\u{A}' | '\u{B}' | '\u{C}' | '\u{D}' | ' ' | '\u{A0}' | '\u{1680}' | '\u{2000}'
..='\u{200A}'
| '\u{2028}'
| '\u{2029}'
| '\u{202F}'
| '\u{205F}'
| '\u{3000}'
| '\u{FEFF}'
)
}
pub fn class_into(out: &mut String, after: bool, items: &[&str]) {
let mut sep = after;
for item in items {
let item = js_trim(item);
if item.is_empty() {
continue;
}
if sep {
out.push(' ');
}
escape_into(out, item);
sep = true;
}
}
pub fn state_script_into(out: &mut String, id: &str, state: &impl Serialize) {
out.push_str("<script type=\"application/json\" id=\"");
escape_into(out, id);
out.push_str("\">");
let json = serde_json::to_string(state).unwrap_or_default();
json_escaped_into(out, &json);
out.push_str("</script>");
}
fn json_escaped_into(out: &mut String, json: &str) {
out.reserve(json.len());
let bytes = json.as_bytes();
let (mut last, mut i) = (0, 0);
while i < bytes.len() {
let (rep, width) = match bytes[i] {
b'<' => ("\\u003c", 1),
b'>' => ("\\u003e", 1),
b'&' => ("\\u0026", 1),
0xE2 if bytes.get(i + 1) == Some(&0x80) && bytes.get(i + 2) == Some(&0xA8) => {
("\\u2028", 3)
}
0xE2 if bytes.get(i + 1) == Some(&0x80) && bytes.get(i + 2) == Some(&0xA9) => {
("\\u2029", 3)
}
_ => {
i += 1;
continue;
}
};
out.push_str(&json[last..i]);
out.push_str(rep);
i += width;
last = i;
}
out.push_str(&json[last..]);
}
fn island_into<P: Serialize>(
out: &mut String,
name: &str,
props: &P,
render: &impl Fn(&mut String, &P),
) {
out.push_str("<div data-island=\"");
escape_into(out, name);
out.push_str("\" data-props=\"");
let json = serde_json::to_string(props).unwrap_or_default();
escape_into(out, &json);
out.push_str("\">");
render(out, props);
out.push_str("</div>");
}
mod router;
pub use router::{Link, Route, Router, query_into};
#[cfg(test)]
mod tests;