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::HashMap,
24 fmt::Write as _,
25 sync::atomic::{AtomicU64, Ordering},
26};
27
28use maud::{Markup, PreEscaped, Render, html};
29
30const SLOT_START_PREFIX: &str = "<!--maud-extensions-slot-start:v1:";
31const SLOT_END_PREFIX: &str = "<!--maud-extensions-slot-end:v1:";
32const SLOT_MARKER_SEPARATOR: char = ':';
33const SLOT_MARKER_SUFFIX: &str = "-->";
34
35#[derive(Default)]
36struct SlotPayload {
37 default_html: String,
38 named_html: HashMap<String, String>,
39}
40
41thread_local! {
42 static SLOT_STACK: RefCell<Vec<SlotPayload>> = const { RefCell::new(Vec::new()) };
43}
44
45static SLOT_MARKER_COUNTER: AtomicU64 = AtomicU64::new(1);
46
47pub struct Slotted<T: Render> {
52 value: T,
53 slot_name: String,
54}
55
56impl<T: Render> Slotted<T> {
57 #[must_use]
59 pub fn new(value: T, slot_name: String) -> Self {
60 Self { value, slot_name }
61 }
62}
63
64impl<T: Render> Render for Slotted<T> {
65 fn render(&self) -> Markup {
66 let marker_id = next_slot_marker_id();
67 let mut start_marker = String::with_capacity(
68 SLOT_START_PREFIX.len() + marker_id.len() + self.slot_name.len() * 2 + 8,
69 );
70 start_marker.push_str(SLOT_START_PREFIX);
71 start_marker.push_str(&marker_id);
72 start_marker.push(SLOT_MARKER_SEPARATOR);
73 start_marker.push_str(&encode_slot_name(&self.slot_name));
74 start_marker.push_str(SLOT_MARKER_SUFFIX);
75
76 let mut end_marker = String::with_capacity(
77 SLOT_END_PREFIX.len() + marker_id.len() + SLOT_MARKER_SUFFIX.len(),
78 );
79 end_marker.push_str(SLOT_END_PREFIX);
80 end_marker.push_str(&marker_id);
81 end_marker.push_str(SLOT_MARKER_SUFFIX);
82
83 html! {
84 (PreEscaped(start_marker))
85 (self.value.render())
86 (PreEscaped(end_marker))
87 }
88 }
89}
90
91pub trait InSlotExt: Render + Sized {
93 #[must_use]
97 fn in_slot(self, slot_name: &str) -> Slotted<Self> {
98 Slotted::new(self, slot_name.to_string())
99 }
100}
101
102impl<T> InSlotExt for T where T: Render {}
103
104pub struct SlottedComponent<T: Render> {
112 component: T,
113 children_html: String,
114}
115
116impl<T: Render> SlottedComponent<T> {
117 #[must_use]
119 pub fn new(component: T, children: Markup) -> Self {
120 Self {
121 component,
122 children_html: children.into_string(),
123 }
124 }
125}
126
127impl<T: Render> Render for SlottedComponent<T> {
128 fn render(&self) -> Markup {
129 let payload = collect_slots_from_children(&self.children_html);
130 SLOT_STACK.with(|stack| {
131 stack.borrow_mut().push(payload);
132 });
133
134 struct SlotGuard;
135 impl Drop for SlotGuard {
136 fn drop(&mut self) {
137 SLOT_STACK.with(|stack| {
138 stack.borrow_mut().pop();
139 });
140 }
141 }
142
143 let _guard = SlotGuard;
144 self.component.render()
145 }
146}
147
148pub trait WithChildrenExt: Render + Sized {
154 #[must_use]
156 fn with_children(self, children: Markup) -> SlottedComponent<Self> {
157 SlottedComponent::new(self, children)
158 }
159}
160
161impl<T> WithChildrenExt for T where T: Render {}
162
163pub mod prelude {
165 pub use crate::{InSlotExt, WithChildrenExt, named_slot, slot};
166}
167
168#[must_use]
172pub fn slot() -> Markup {
173 current_slot_html(|payload| payload.default_html.clone())
174 .map(PreEscaped)
175 .unwrap_or_else(empty_markup)
176}
177
178#[must_use]
183pub fn named_slot(slot_name: &str) -> Markup {
184 current_slot_html(|payload| payload.named_html.get(slot_name).cloned())
185 .flatten()
186 .map(PreEscaped)
187 .unwrap_or_else(empty_markup)
188}
189
190fn current_slot_html<T>(f: impl FnOnce(&SlotPayload) -> T) -> Option<T> {
191 SLOT_STACK.with(|stack| {
192 let stack = stack.borrow();
193 stack.last().map(f)
194 })
195}
196
197fn empty_markup() -> Markup {
198 PreEscaped(String::new())
199}
200
201fn next_slot_marker_id() -> String {
202 format!(
203 "{:016x}",
204 SLOT_MARKER_COUNTER.fetch_add(1, Ordering::Relaxed)
205 )
206}
207
208fn collect_slots_from_children(children_html: &str) -> SlotPayload {
209 let mut payload = SlotPayload::default();
210 let mut cursor = 0usize;
211
212 while let Some(start_rel) = children_html[cursor..].find(SLOT_START_PREFIX) {
213 let slot_marker_start = cursor + start_rel;
214 payload
215 .default_html
216 .push_str(&children_html[cursor..slot_marker_start]);
217
218 let marker_content_start = slot_marker_start + SLOT_START_PREFIX.len();
219 let Some(marker_end_rel) = children_html[marker_content_start..].find(SLOT_MARKER_SUFFIX)
220 else {
221 payload
222 .default_html
223 .push_str(&children_html[slot_marker_start..]);
224 return payload;
225 };
226 let marker_content_end = marker_content_start + marker_end_rel;
227 let marker_content = &children_html[marker_content_start..marker_content_end];
228 let Some((marker_id, encoded_name)) = marker_content.split_once(SLOT_MARKER_SEPARATOR)
229 else {
230 payload
231 .default_html
232 .push_str(&children_html[slot_marker_start..]);
233 return payload;
234 };
235 if marker_id.is_empty() {
236 payload
237 .default_html
238 .push_str(&children_html[slot_marker_start..]);
239 return payload;
240 }
241
242 let slot_name = decode_slot_name(encoded_name).unwrap_or_else(|| encoded_name.to_string());
243 let slot_content_start = marker_content_end + SLOT_MARKER_SUFFIX.len();
244
245 let mut end_marker = String::with_capacity(
246 SLOT_END_PREFIX.len() + marker_id.len() + SLOT_MARKER_SUFFIX.len(),
247 );
248 end_marker.push_str(SLOT_END_PREFIX);
249 end_marker.push_str(marker_id);
250 end_marker.push_str(SLOT_MARKER_SUFFIX);
251
252 let Some(slot_end_rel) = children_html[slot_content_start..].find(&end_marker) else {
253 payload
254 .default_html
255 .push_str(&children_html[slot_marker_start..]);
256 return payload;
257 };
258 let slot_content_end = slot_content_start + slot_end_rel;
259 let slot_content = &children_html[slot_content_start..slot_content_end];
260
261 payload
262 .named_html
263 .entry(slot_name)
264 .or_default()
265 .push_str(slot_content);
266
267 cursor = slot_content_end + end_marker.len();
268 }
269
270 payload.default_html.push_str(&children_html[cursor..]);
271 payload
272}
273
274fn encode_slot_name(name: &str) -> String {
275 let mut out = String::with_capacity(name.len() * 2);
276 for byte in name.as_bytes() {
277 let _ = write!(&mut out, "{byte:02x}");
278 }
279 out
280}
281
282fn decode_slot_name(encoded_name: &str) -> Option<String> {
283 if encoded_name.is_empty() || encoded_name.len() % 2 != 0 {
284 return None;
285 }
286
287 let mut bytes = Vec::with_capacity(encoded_name.len() / 2);
288 for chunk in encoded_name.as_bytes().chunks_exact(2) {
289 let chunk = std::str::from_utf8(chunk).ok()?;
290 let byte = u8::from_str_radix(chunk, 16).ok()?;
291 bytes.push(byte);
292 }
293
294 String::from_utf8(bytes).ok()
295}