#![doc = include_str!("../docs/crate.md")]
#![cfg_attr(docsrs, feature(doc_cfg))]
#![warn(missing_docs, rustdoc::missing_crate_level_docs)]
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")]
#[cfg_attr(docsrs, doc(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),
Slotted(&'s dyn Fn(&mut String, &str) -> 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),
}
}
#[doc(hidden)]
pub fn slotted(render: &'s dyn Fn(&mut String, &str) -> bool) -> Self {
Slot {
body: Body::Slotted(render),
}
}
pub fn render_to(&self, out: &mut String) {
match self.body {
Body::Content(f) => f(out),
Body::Markup(f) => {
f(out);
}
Body::Slotted(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 {
slot_into_slotted(out, slot, "", fallback)
}
pub fn slot_into_slotted(
out: &mut String,
slot: Option<Slot<'_>>,
slot_scope_id: &str,
fallback: Option<&mut dyn FnMut(&mut String)>,
) -> bool {
out.push_str("<!--[-->");
let start = out.len();
let filled = match slot.map(|s| s.body) {
Some(Body::Content(f)) => {
f(out);
true
}
Some(Body::Markup(f)) => f(out),
Some(Body::Slotted(f)) => f(out, &content_scope_id(slot_scope_id)),
None => false,
};
if !filled {
out.truncate(start);
if let Some(fallback) = fallback {
fallback(out);
}
}
out.push_str("<!--]-->");
filled
}
fn content_scope_id(slot_scope_id: &str) -> std::borrow::Cow<'_, str> {
if slot_scope_id.is_empty() {
std::borrow::Cow::Borrowed("")
} else {
std::borrow::Cow::Owned(format!(" {slot_scope_id}"))
}
}
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 fn scoped_slot_into_slotted<P: ?Sized, F: Fn(&mut String, &P, &str) -> bool + ?Sized>(
out: &mut String,
slot: Option<&F>,
props: &P,
slot_scope_id: &str,
fallback: Option<&mut dyn FnMut(&mut String)>,
) -> bool {
let id = content_scope_id(slot_scope_id);
let content = slot.map(|f| move |out: &mut String, p: &P| f(out, p, &id));
scoped_slot_into(out, content.as_ref(), props, fallback)
}
pub fn scope_attrs(inherited: &str, own: &str, slotted: &str) -> String {
let mut keys: Vec<&str> = inherited.split(' ').filter(|k| !k.is_empty()).collect();
for key in std::iter::once(own).chain(js_trim(slotted).split(' ')) {
if !key.is_empty() && !keys.contains(&key) {
keys.push(key);
}
}
let mut out = String::with_capacity(keys.iter().map(|k| k.len() + 1).sum());
for key in keys {
out.push(' ');
out.push_str(key);
}
out
}
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) {
if !needs_escape(s.as_bytes()) {
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..]);
}
fn needs_escape(s: &[u8]) -> bool {
const ONES: u64 = u64::from_ne_bytes([0x01; 8]);
const TOPS: u64 = u64::from_ne_bytes([0x80; 8]);
let special = |word: u64| {
let zero = |c: u8| {
let t = word ^ (ONES * u64::from(c));
t.wrapping_sub(ONES) & !t & TOPS
};
(zero(b'"') | zero(b'&') | zero(b'\'') | zero(b'<') | zero(b'>')) != 0
};
let Some(last) = s.last_chunk::<8>() else {
return s
.iter()
.any(|b| matches!(b, b'"' | b'&' | b'\'' | b'<' | b'>'));
};
let (words, _) = s.as_chunks::<8>();
words.iter().any(|w| special(u64::from_ne_bytes(*w))) || special(u64::from_ne_bytes(*last))
}
const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1;
const DIGIT_PAIRS: &[u8; 200] = b"\
0001020304050607080910111213141516171819\
2021222324252627282930313233343536373839\
4041424344454647484950515253545556575859\
6061626364656667686970717273747576777879\
8081828384858687888990919293949596979899";
fn decimal(buf: &mut [u8; 20], mut n: u64) -> usize {
let mut i = buf.len();
while n >= 100 {
let pair = (n % 100) as usize * 2;
n /= 100;
i -= 2;
buf[i..i + 2].copy_from_slice(&DIGIT_PAIRS[pair..pair + 2]);
}
if n >= 10 {
let pair = n as usize * 2;
i -= 2;
buf[i..i + 2].copy_from_slice(&DIGIT_PAIRS[pair..pair + 2]);
} else {
i -= 1;
buf[i] = b'0' + n as u8;
}
i
}
fn push_decimal(out: &mut String, n: u64) {
if n < 10 {
out.push(char::from(b'0' + n as u8));
return;
}
let mut buf = [0; 20];
let start = decimal(&mut buf, n);
out.reserve(buf.len() - start);
for &b in &buf[start..] {
out.push(char::from(b));
}
}
pub fn push_int(out: &mut String, n: i64) {
if n.unsigned_abs() <= MAX_SAFE_INTEGER {
if n < 0 {
out.push('-');
}
push_decimal(out, n.unsigned_abs());
} else {
push_number(out, n as f64);
}
}
struct Short {
buf: [u8; 32],
len: usize,
}
impl std::fmt::Write for Short {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
let end = self.len + s.len();
self.buf
.get_mut(self.len..end)
.ok_or(std::fmt::Error)?
.copy_from_slice(s.as_bytes());
self.len = end;
Ok(())
}
}
pub fn push_number(out: &mut String, x: f64) {
use std::fmt::Write;
if x.is_nan() {
out.push_str("NaN");
return;
}
if x == 0.0 {
out.push('0');
return;
}
if x.is_infinite() {
out.push_str(if x < 0.0 { "-Infinity" } else { "Infinity" });
return;
}
if x < 0.0 {
out.push('-');
}
let x = x.abs();
if x.fract() == 0.0 && x <= MAX_SAFE_INTEGER as f64 {
push_decimal(out, x as u64);
return;
}
let mut sci = Short {
buf: [0; 32],
len: 0,
};
let _ = write!(sci, "{x:e}");
let sci = std::str::from_utf8(&sci.buf[..sci.len]).expect("`{:e}` writes ASCII");
let (mantissa, exp) = sci.split_once('e').expect("`{:e}` writes an exponent");
let exp: i32 = exp.parse().expect("`{:e}` writes an integer exponent");
let mut digits: String = mantissa.chars().filter(|c| *c != '.').collect();
if let Some((exact, exact_exp)) = few_exact_digits(x)
&& exact_exp == exp
{
let k = digits.len();
if exact.len() == k + 1 && exact.ends_with('5') {
let lower = &exact[..k];
let upper = increment_digits(lower);
let even = |d: &str| {
d.bytes()
.last()
.is_some_and(|b| (b - b'0').is_multiple_of(2))
};
if upper.len() == k && (digits == lower || digits == upper) {
digits = if even(lower) { lower.to_owned() } else { upper };
}
}
}
let k = digits.len() as i32;
let n = exp + 1;
if k <= n && n <= 21 {
out.push_str(&digits);
out.extend(std::iter::repeat_n('0', (n - k) as usize));
} else if 0 < n && n <= 21 {
out.push_str(&digits[..n as usize]);
out.push('.');
out.push_str(&digits[n as usize..]);
} else if -6 < n && n <= 0 {
out.push_str("0.");
out.extend(std::iter::repeat_n('0', (-n) as usize));
out.push_str(&digits);
} else {
out.push_str(&digits[..1]);
if k > 1 {
out.push('.');
out.push_str(&digits[1..]);
}
let e = n - 1;
let _ = write!(out, "e{}{}", if e < 0 { '-' } else { '+' }, e.abs());
}
}
fn few_exact_digits(x: f64) -> Option<(String, i32)> {
let bits = x.to_bits();
let biased = ((bits >> 52) & 0x7ff) as i32;
let fraction = bits & ((1 << 52) - 1);
let (m, e) = if biased == 0 {
(fraction, -1074)
} else {
(fraction | 1 << 52, biased - 1075)
};
let shift = m.trailing_zeros();
let (m, e) = (m >> shift, e + shift as i32);
let (value, scale) = if e < 0 {
if -e >= 26 {
return None;
}
(u128::from(m) * 5u128.pow(e.unsigned_abs()), e)
} else if e <= 74 {
(u128::from(m) << e, 0)
} else if e < 82 {
use std::fmt::Write;
let mut s = String::new();
let _ = write!(s, "{x:.40e}");
let (mantissa, exp) = s.split_once('e')?;
let digits: String = mantissa.chars().filter(|c| *c != '.').collect();
let digits = digits.trim_end_matches('0');
let exp = exp.parse().ok()?;
return (digits.len() <= 18).then(|| (digits.to_owned(), exp));
} else {
return None;
};
let all = value.to_string();
let digits = all.trim_end_matches('0');
(digits.len() <= 18).then(|| (digits.to_owned(), all.len() as i32 - 1 + scale))
}
pub fn js_round(x: f64) -> f64 {
let f = x.floor();
let r = if x - f >= 0.5 { f + 1.0 } else { f };
if r == 0.0 && x.is_sign_negative() {
-0.0
} else {
r
}
}
pub fn js_max(a: f64, b: f64) -> f64 {
if a.is_nan() || b.is_nan() {
f64::NAN
} else if a > b || (a == b && b.is_sign_negative()) {
a
} else {
b
}
}
pub fn js_min(a: f64, b: f64) -> f64 {
if a.is_nan() || b.is_nan() {
f64::NAN
} else if a < b || (a == b && a.is_sign_negative()) {
a
} else {
b
}
}
pub fn js_to_fixed(x: f64, digits: u32) -> String {
use std::fmt::Write;
if x.is_nan() {
return "NaN".to_owned();
}
if x.abs() >= 1e21 || x.is_infinite() {
let mut s = String::new();
push_number(&mut s, x);
return s;
}
let d = digits as usize;
let mut out = String::new();
let _ = write!(out, "{:.*}", d, x.abs());
let mut exact = String::new();
let _ = write!(exact, "{:.1100}", x.abs());
let exact = exact.trim_end_matches('0');
let fraction = exact.split_once('.').map_or("", |(_, f)| f);
if fraction.len() == d + 1 && fraction.ends_with('5') {
out = round_up_magnitude(&exact[..exact.len() - 1]);
}
if x < 0.0 {
out.insert(0, '-');
}
out
}
fn round_up_magnitude(digits: &str) -> String {
let digits = digits.trim_end_matches('.');
let mut bytes: Vec<u8> = digits.bytes().collect();
let mut i = bytes.len();
loop {
if i == 0 {
bytes.insert(0, b'1');
break;
}
i -= 1;
match bytes[i] {
b'.' => continue,
b'9' => bytes[i] = b'0',
d => {
bytes[i] = d + 1;
break;
}
}
}
String::from_utf8(bytes).expect("ASCII digits")
}
fn increment_digits(digits: &str) -> String {
let mut bytes: Vec<u8> = digits.bytes().collect();
for b in bytes.iter_mut().rev() {
if *b == b'9' {
*b = b'0';
} else {
*b += 1;
return String::from_utf8(bytes).expect("ASCII digits");
}
}
bytes.insert(0, b'1');
String::from_utf8(bytes).expect("ASCII digits")
}
pub struct Js<T>(pub T);
impl std::fmt::Display for Js<i64> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.0.unsigned_abs() > MAX_SAFE_INTEGER {
return Js(self.0 as f64).fmt(f);
}
let mut buf = [0; 20];
let start = decimal(&mut buf, self.0.unsigned_abs());
if self.0 < 0 {
f.write_str("-")?;
}
f.write_str(std::str::from_utf8(&buf[start..]).expect("ASCII digits"))
}
}
impl std::fmt::Display for Js<f64> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut s = String::new();
push_number(&mut s, self.0);
f.write_str(&s)
}
}
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 class_object(entries: &[(bool, &str)]) -> String {
let mut names: Vec<(&str, bool)> = Vec::new();
for (on, name) in entries {
match names.iter_mut().find(|(n, _)| n == name) {
Some(entry) => entry.1 = *on,
None => names.push((name, *on)),
}
}
let mut ordered: Vec<(Option<u32>, &str, bool)> = names
.into_iter()
.map(|(n, on)| (record::array_index(n), n, on))
.collect();
ordered.sort_by_key(|(i, _, _)| i.map_or((1, 0), |i| (0, i)));
let mut s = String::new();
for (_, name, on) in ordered {
if on {
s.push_str(name);
s.push(' ');
}
}
js_trim(&s).to_owned()
}
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>");
}
#[cfg(any(doc, doctest))]
pub mod guide;
pub mod i18n;
mod record;
mod router;
mod strings;
mod teleport;
pub use i18n::I18n;
pub use record::Record;
pub use router::{Link, Query, Route, RouteDef, Router, query_into};
pub use strings::{
js_at, js_char_at, js_cmp, js_index_of, js_json_number, js_json_string, js_last_index_of,
js_number, js_pad_end, js_pad_start, js_parse_float, js_parse_int, js_repeat, js_replace,
js_replace_all, js_slice, js_slice_items, js_slice_range, js_split, js_substring,
};
pub use teleport::{Teleports, teleport_into};
#[cfg(test)]
mod tests;