#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]
use std::{
cell::RefCell,
collections::HashMap,
fmt::Write as _,
sync::atomic::{AtomicU64, Ordering},
};
use maud::{Markup, PreEscaped, Render, html};
const SLOT_START_PREFIX: &str = "<!--maud-extensions-slot-start:v1:";
const SLOT_END_PREFIX: &str = "<!--maud-extensions-slot-end:v1:";
const SLOT_MARKER_SEPARATOR: char = ':';
const SLOT_MARKER_SUFFIX: &str = "-->";
#[derive(Default)]
struct SlotPayload {
default_html: String,
named_html: HashMap<String, String>,
}
thread_local! {
static SLOT_STACK: RefCell<Vec<SlotPayload>> = const { RefCell::new(Vec::new()) };
}
static SLOT_MARKER_COUNTER: AtomicU64 = AtomicU64::new(1);
pub struct Slotted<T: Render> {
value: T,
slot_name: String,
}
impl<T: Render> Slotted<T> {
#[must_use]
pub fn new(value: T, slot_name: String) -> Self {
Self { value, slot_name }
}
}
impl<T: Render> Render for Slotted<T> {
fn render(&self) -> Markup {
let marker_id = next_slot_marker_id();
let mut start_marker = String::with_capacity(
SLOT_START_PREFIX.len() + marker_id.len() + self.slot_name.len() * 2 + 8,
);
start_marker.push_str(SLOT_START_PREFIX);
start_marker.push_str(&marker_id);
start_marker.push(SLOT_MARKER_SEPARATOR);
start_marker.push_str(&encode_slot_name(&self.slot_name));
start_marker.push_str(SLOT_MARKER_SUFFIX);
let mut end_marker = String::with_capacity(
SLOT_END_PREFIX.len() + marker_id.len() + SLOT_MARKER_SUFFIX.len(),
);
end_marker.push_str(SLOT_END_PREFIX);
end_marker.push_str(&marker_id);
end_marker.push_str(SLOT_MARKER_SUFFIX);
html! {
(PreEscaped(start_marker))
(self.value.render())
(PreEscaped(end_marker))
}
}
}
pub trait InSlotExt: Render + Sized {
#[must_use]
fn in_slot(self, slot_name: &str) -> Slotted<Self> {
Slotted::new(self, slot_name.to_string())
}
}
impl<T> InSlotExt for T where T: Render {}
pub struct SlottedComponent<T: Render> {
component: T,
children_html: String,
}
impl<T: Render> SlottedComponent<T> {
#[must_use]
pub fn new(component: T, children: Markup) -> Self {
Self {
component,
children_html: children.into_string(),
}
}
}
impl<T: Render> Render for SlottedComponent<T> {
fn render(&self) -> Markup {
let payload = collect_slots_from_children(&self.children_html);
SLOT_STACK.with(|stack| {
stack.borrow_mut().push(payload);
});
struct SlotGuard;
impl Drop for SlotGuard {
fn drop(&mut self) {
SLOT_STACK.with(|stack| {
stack.borrow_mut().pop();
});
}
}
let _guard = SlotGuard;
self.component.render()
}
}
pub trait WithChildrenExt: Render + Sized {
#[must_use]
fn with_children(self, children: Markup) -> SlottedComponent<Self> {
SlottedComponent::new(self, children)
}
}
impl<T> WithChildrenExt for T where T: Render {}
pub mod prelude {
pub use crate::{InSlotExt, WithChildrenExt, named_slot, slot};
}
#[must_use]
pub fn slot() -> Markup {
current_slot_html(|payload| payload.default_html.clone())
.map(PreEscaped)
.unwrap_or_else(empty_markup)
}
#[must_use]
pub fn named_slot(slot_name: &str) -> Markup {
current_slot_html(|payload| payload.named_html.get(slot_name).cloned())
.flatten()
.map(PreEscaped)
.unwrap_or_else(empty_markup)
}
fn current_slot_html<T>(f: impl FnOnce(&SlotPayload) -> T) -> Option<T> {
SLOT_STACK.with(|stack| {
let stack = stack.borrow();
stack.last().map(f)
})
}
fn empty_markup() -> Markup {
PreEscaped(String::new())
}
fn next_slot_marker_id() -> String {
format!(
"{:016x}",
SLOT_MARKER_COUNTER.fetch_add(1, Ordering::Relaxed)
)
}
fn collect_slots_from_children(children_html: &str) -> SlotPayload {
let mut payload = SlotPayload::default();
let mut cursor = 0usize;
while let Some(start_rel) = children_html[cursor..].find(SLOT_START_PREFIX) {
let slot_marker_start = cursor + start_rel;
payload
.default_html
.push_str(&children_html[cursor..slot_marker_start]);
let marker_content_start = slot_marker_start + SLOT_START_PREFIX.len();
let Some(marker_end_rel) = children_html[marker_content_start..].find(SLOT_MARKER_SUFFIX)
else {
payload
.default_html
.push_str(&children_html[slot_marker_start..]);
return payload;
};
let marker_content_end = marker_content_start + marker_end_rel;
let marker_content = &children_html[marker_content_start..marker_content_end];
let Some((marker_id, encoded_name)) = marker_content.split_once(SLOT_MARKER_SEPARATOR)
else {
payload
.default_html
.push_str(&children_html[slot_marker_start..]);
return payload;
};
if marker_id.is_empty() {
payload
.default_html
.push_str(&children_html[slot_marker_start..]);
return payload;
}
let slot_name = decode_slot_name(encoded_name).unwrap_or_else(|| encoded_name.to_string());
let slot_content_start = marker_content_end + SLOT_MARKER_SUFFIX.len();
let mut end_marker = String::with_capacity(
SLOT_END_PREFIX.len() + marker_id.len() + SLOT_MARKER_SUFFIX.len(),
);
end_marker.push_str(SLOT_END_PREFIX);
end_marker.push_str(marker_id);
end_marker.push_str(SLOT_MARKER_SUFFIX);
let Some(slot_end_rel) = children_html[slot_content_start..].find(&end_marker) else {
payload
.default_html
.push_str(&children_html[slot_marker_start..]);
return payload;
};
let slot_content_end = slot_content_start + slot_end_rel;
let slot_content = &children_html[slot_content_start..slot_content_end];
payload
.named_html
.entry(slot_name)
.or_default()
.push_str(slot_content);
cursor = slot_content_end + end_marker.len();
}
payload.default_html.push_str(&children_html[cursor..]);
payload
}
fn encode_slot_name(name: &str) -> String {
let mut out = String::with_capacity(name.len() * 2);
for byte in name.as_bytes() {
let _ = write!(&mut out, "{byte:02x}");
}
out
}
fn decode_slot_name(encoded_name: &str) -> Option<String> {
if encoded_name.is_empty() || encoded_name.len() % 2 != 0 {
return None;
}
let mut bytes = Vec::with_capacity(encoded_name.len() / 2);
for chunk in encoded_name.as_bytes().chunks_exact(2) {
let chunk = std::str::from_utf8(chunk).ok()?;
let byte = u8::from_str_radix(chunk, 16).ok()?;
bytes.push(byte);
}
String::from_utf8(bytes).ok()
}