maud_extensions_runtime/
lib.rs1#![forbid(unsafe_code)]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4use std::{
22 cell::RefCell,
23 collections::hash_map::RandomState,
24 collections::HashMap,
25 fmt::Write as _,
26 hash::{BuildHasher, Hash, Hasher},
27 sync::atomic::{AtomicU64, Ordering},
28 sync::OnceLock,
29};
30
31use maud::{Markup, PreEscaped, Render, html};
32
33const SLOT_START_PREFIX: &str = "<!--maud-extensions-slot-start:v1:";
34const SLOT_END_PREFIX: &str = "<!--maud-extensions-slot-end:v1:";
35const SLOT_MARKER_SEPARATOR: char = ':';
36const SLOT_MARKER_SUFFIX: &str = "-->";
37
38#[derive(Default)]
39struct SlotPayload {
40 default_html: String,
41 named_html: HashMap<String, String>,
42}
43
44thread_local! {
45 static SLOT_STACK: RefCell<Vec<SlotPayload>> = const { RefCell::new(Vec::new()) };
46}
47
48static SLOT_MARKER_COUNTER: AtomicU64 = AtomicU64::new(1);
49
50fn slot_marker_hasher() -> &'static RandomState {
51 static SLOT_MARKER_HASHER: OnceLock<RandomState> = OnceLock::new();
52 SLOT_MARKER_HASHER.get_or_init(RandomState::new)
53}
54
55fn slot_marker_auth(marker_id: &str, slot_name: &str) -> String {
56 let mut hasher = slot_marker_hasher().build_hasher();
57 marker_id.hash(&mut hasher);
58 slot_name.hash(&mut hasher);
59 format!("{:016x}", hasher.finish())
60}
61
62pub struct Slotted<T: Render> {
67 value: T,
68 slot_name: String,
69}
70
71impl<T: Render> Slotted<T> {
72 #[must_use]
74 pub fn new(value: T, slot_name: String) -> Self {
75 Self { value, slot_name }
76 }
77}
78
79impl<T: Render> Render for Slotted<T> {
80 fn render(&self) -> Markup {
81 let marker_id = next_slot_marker_id();
82 let marker_auth = slot_marker_auth(&marker_id, &self.slot_name);
83 let mut start_marker = String::with_capacity(
84 SLOT_START_PREFIX.len() + marker_id.len() + self.slot_name.len() * 2 + marker_auth.len() + 16,
85 );
86 start_marker.push_str(SLOT_START_PREFIX);
87 start_marker.push_str(&marker_id);
88 start_marker.push(SLOT_MARKER_SEPARATOR);
89 start_marker.push_str(&encode_slot_name(&self.slot_name));
90 start_marker.push(SLOT_MARKER_SEPARATOR);
91 start_marker.push_str(&marker_auth);
92 start_marker.push_str(SLOT_MARKER_SUFFIX);
93
94 let mut end_marker = String::with_capacity(
95 SLOT_END_PREFIX.len() + marker_id.len() + SLOT_MARKER_SUFFIX.len(),
96 );
97 end_marker.push_str(SLOT_END_PREFIX);
98 end_marker.push_str(&marker_id);
99 end_marker.push_str(SLOT_MARKER_SUFFIX);
100
101 html! {
102 (PreEscaped(start_marker))
103 (self.value.render())
104 (PreEscaped(end_marker))
105 }
106 }
107}
108
109pub trait InSlotExt: Render + Sized {
111 #[must_use]
115 fn in_slot(self, slot_name: &str) -> Slotted<Self> {
116 Slotted::new(self, slot_name.to_string())
117 }
118}
119
120impl<T> InSlotExt for T where T: Render {}
121
122pub struct SlottedComponent<T: Render> {
130 component: T,
131 children_html: String,
132}
133
134impl<T: Render> SlottedComponent<T> {
135 #[must_use]
137 pub fn new(component: T, children: Markup) -> Self {
138 Self {
139 component,
140 children_html: children.into_string(),
141 }
142 }
143}
144
145impl<T: Render> Render for SlottedComponent<T> {
146 fn render(&self) -> Markup {
147 let payload = collect_slots_from_children(&self.children_html);
148 SLOT_STACK.with(|stack| {
149 stack.borrow_mut().push(payload);
150 });
151
152 struct SlotGuard;
153 impl Drop for SlotGuard {
154 fn drop(&mut self) {
155 SLOT_STACK.with(|stack| {
156 stack.borrow_mut().pop();
157 });
158 }
159 }
160
161 let _guard = SlotGuard;
162 self.component.render()
163 }
164}
165
166pub trait WithChildrenExt: Render + Sized {
172 #[must_use]
174 fn with_children(self, children: Markup) -> SlottedComponent<Self> {
175 SlottedComponent::new(self, children)
176 }
177}
178
179impl<T> WithChildrenExt for T where T: Render {}
180
181pub mod prelude {
183 pub use crate::{InSlotExt, WithChildrenExt, named_slot, slot};
184}
185
186#[must_use]
190pub fn slot() -> Markup {
191 current_slot_html(|payload| payload.default_html.clone())
192 .map(PreEscaped)
193 .unwrap_or_else(empty_markup)
194}
195
196#[must_use]
201pub fn named_slot(slot_name: &str) -> Markup {
202 current_slot_html(|payload| payload.named_html.get(slot_name).cloned())
203 .flatten()
204 .map(PreEscaped)
205 .unwrap_or_else(empty_markup)
206}
207
208fn current_slot_html<T>(f: impl FnOnce(&SlotPayload) -> T) -> Option<T> {
209 SLOT_STACK.with(|stack| {
210 let stack = stack.borrow();
211 stack.last().map(f)
212 })
213}
214
215fn empty_markup() -> Markup {
216 PreEscaped(String::new())
217}
218
219fn next_slot_marker_id() -> String {
220 format!(
221 "{:016x}",
222 SLOT_MARKER_COUNTER.fetch_add(1, Ordering::Relaxed)
223 )
224}
225
226fn collect_slots_from_children(children_html: &str) -> SlotPayload {
227 let mut payload = SlotPayload::default();
228 let mut cursor = 0usize;
229
230 while let Some(start_rel) = children_html[cursor..].find(SLOT_START_PREFIX) {
231 let slot_marker_start = cursor + start_rel;
232 payload
233 .default_html
234 .push_str(&children_html[cursor..slot_marker_start]);
235
236 let marker_content_start = slot_marker_start + SLOT_START_PREFIX.len();
237 let Some(marker_end_rel) = children_html[marker_content_start..].find(SLOT_MARKER_SUFFIX)
238 else {
239 payload
240 .default_html
241 .push_str(&children_html[slot_marker_start..]);
242 return payload;
243 };
244 let marker_content_end = marker_content_start + marker_end_rel;
245 let marker_content = &children_html[marker_content_start..marker_content_end];
246 let mut marker_parts = marker_content.split(SLOT_MARKER_SEPARATOR);
247 let Some(marker_id) = marker_parts.next()
248 else {
249 payload
250 .default_html
251 .push_str(&children_html[slot_marker_start..]);
252 return payload;
253 };
254 let Some(encoded_name) = marker_parts.next()
255 else {
256 payload
257 .default_html
258 .push_str(&children_html[slot_marker_start..]);
259 return payload;
260 };
261 let Some(marker_auth) = marker_parts.next()
262 else {
263 payload
264 .default_html
265 .push_str(&children_html[slot_marker_start..]);
266 return payload;
267 };
268 if marker_id.is_empty() || marker_parts.next().is_some() {
269 payload
270 .default_html
271 .push_str(&children_html[slot_marker_start..]);
272 return payload;
273 }
274
275 let Some(slot_name) = decode_slot_name(encoded_name) else {
276 payload
277 .default_html
278 .push_str(&children_html[slot_marker_start..]);
279 return payload;
280 };
281 if marker_auth != slot_marker_auth(marker_id, &slot_name) {
282 payload
283 .default_html
284 .push_str(&children_html[slot_marker_start..]);
285 return payload;
286 }
287 let slot_content_start = marker_content_end + SLOT_MARKER_SUFFIX.len();
288
289 let mut end_marker = String::with_capacity(
290 SLOT_END_PREFIX.len() + marker_id.len() + SLOT_MARKER_SUFFIX.len(),
291 );
292 end_marker.push_str(SLOT_END_PREFIX);
293 end_marker.push_str(marker_id);
294 end_marker.push_str(SLOT_MARKER_SUFFIX);
295
296 let Some(slot_end_rel) = children_html[slot_content_start..].find(&end_marker) else {
297 payload
298 .default_html
299 .push_str(&children_html[slot_marker_start..]);
300 return payload;
301 };
302 let slot_content_end = slot_content_start + slot_end_rel;
303 let slot_content = &children_html[slot_content_start..slot_content_end];
304
305 payload
306 .named_html
307 .entry(slot_name)
308 .or_default()
309 .push_str(slot_content);
310
311 cursor = slot_content_end + end_marker.len();
312 }
313
314 payload.default_html.push_str(&children_html[cursor..]);
315 payload
316}
317
318fn encode_slot_name(name: &str) -> String {
319 let mut out = String::with_capacity(name.len() * 2);
320 for byte in name.as_bytes() {
321 let _ = write!(&mut out, "{byte:02x}");
322 }
323 out
324}
325
326fn decode_slot_name(encoded_name: &str) -> Option<String> {
327 if encoded_name.is_empty() {
328 return Some(String::new());
329 }
330
331 if encoded_name.len() % 2 != 0 {
332 return None;
333 }
334
335 let mut bytes = Vec::with_capacity(encoded_name.len() / 2);
336 for chunk in encoded_name.as_bytes().chunks_exact(2) {
337 let chunk = std::str::from_utf8(chunk).ok()?;
338 let byte = u8::from_str_radix(chunk, 16).ok()?;
339 bytes.push(byte);
340 }
341
342 String::from_utf8(bytes).ok()
343}