maud_extensions_runtime/
lib.rs1use std::{cell::RefCell, collections::HashMap, fmt::Write as _};
2
3use maud::{Markup, PreEscaped, Render, html};
4
5const SLOT_START_PREFIX: &str = "<!--mx-slot-start:";
6const SLOT_START_SUFFIX: &str = "-->";
7const SLOT_END_MARKER: &str = "<!--mx-slot-end-->";
8
9#[derive(Default)]
10struct SlotPayload {
11 default_html: String,
12 named_html: HashMap<String, String>,
13}
14
15thread_local! {
16 static SLOT_STACK: RefCell<Vec<SlotPayload>> = RefCell::new(Vec::new());
17}
18
19pub struct Slotted<T: Render> {
20 value: T,
21 slot_name: String,
22}
23
24impl<T: Render> Slotted<T> {
25 pub fn new(value: T, slot_name: String) -> Self {
26 Self { value, slot_name }
27 }
28}
29
30impl<T: Render> Render for Slotted<T> {
31 fn render(&self) -> Markup {
32 let mut start_marker =
33 String::with_capacity(SLOT_START_PREFIX.len() + self.slot_name.len() * 2 + 3);
34 start_marker.push_str(SLOT_START_PREFIX);
35 start_marker.push_str(&encode_slot_name(&self.slot_name));
36 start_marker.push_str(SLOT_START_SUFFIX);
37
38 html! {
39 (PreEscaped(start_marker))
40 (self.value.render())
41 (PreEscaped(SLOT_END_MARKER.to_string()))
42 }
43 }
44}
45
46pub trait InSlotExt: Render + Sized {
47 fn in_slot(self, slot_name: &str) -> Slotted<Self> {
48 Slotted::new(self, slot_name.to_string())
49 }
50}
51
52impl<T> InSlotExt for T where T: Render {}
53
54pub struct SlottedComponent<T: Render> {
55 component: T,
56 children_html: String,
57}
58
59impl<T: Render> SlottedComponent<T> {
60 pub fn new(component: T, children: Markup) -> Self {
61 Self {
62 component,
63 children_html: children.into_string(),
64 }
65 }
66}
67
68impl<T: Render> Render for SlottedComponent<T> {
69 fn render(&self) -> Markup {
70 let payload = collect_slots_from_children(self.children_html.clone());
71 SLOT_STACK.with(|stack| {
72 stack.borrow_mut().push(payload);
73 });
74
75 struct SlotGuard;
76 impl Drop for SlotGuard {
77 fn drop(&mut self) {
78 SLOT_STACK.with(|stack| {
79 stack.borrow_mut().pop();
80 });
81 }
82 }
83
84 let _guard = SlotGuard;
85 self.component.render()
86 }
87}
88
89pub trait WithChildrenExt: Render + Sized {
90 fn with_children(self, children: Markup) -> SlottedComponent<Self> {
91 SlottedComponent::new(self, children)
92 }
93}
94
95impl<T> WithChildrenExt for T where T: Render {}
96
97pub fn slot() -> Markup {
98 current_slot_html(|payload| payload.default_html.clone())
99 .map(PreEscaped)
100 .unwrap_or_else(empty_markup)
101}
102
103pub fn named_slot(slot_name: &str) -> Markup {
104 current_slot_html(|payload| payload.named_html.get(slot_name).cloned())
105 .flatten()
106 .map(PreEscaped)
107 .unwrap_or_else(empty_markup)
108}
109
110fn current_slot_html<T>(f: impl FnOnce(&SlotPayload) -> T) -> Option<T> {
111 SLOT_STACK.with(|stack| {
112 let stack = stack.borrow();
113 stack.last().map(f)
114 })
115}
116
117fn empty_markup() -> Markup {
118 PreEscaped(String::new())
119}
120
121fn collect_slots_from_children(children_html: String) -> SlotPayload {
122 let mut payload = SlotPayload::default();
123 let mut cursor = 0usize;
124
125 while let Some(start_rel) = children_html[cursor..].find(SLOT_START_PREFIX) {
126 let slot_marker_start = cursor + start_rel;
127 payload
128 .default_html
129 .push_str(&children_html[cursor..slot_marker_start]);
130
131 let encoded_name_start = slot_marker_start + SLOT_START_PREFIX.len();
132 let Some(name_end_rel) = children_html[encoded_name_start..].find(SLOT_START_SUFFIX) else {
133 payload
134 .default_html
135 .push_str(&children_html[slot_marker_start..]);
136 return payload;
137 };
138 let encoded_name_end = encoded_name_start + name_end_rel;
139 let encoded_name = &children_html[encoded_name_start..encoded_name_end];
140 let slot_name = decode_slot_name(encoded_name).unwrap_or_else(|| encoded_name.to_string());
141
142 let slot_content_start = encoded_name_end + SLOT_START_SUFFIX.len();
143 let Some(slot_end_rel) = children_html[slot_content_start..].find(SLOT_END_MARKER) else {
144 payload
145 .default_html
146 .push_str(&children_html[slot_marker_start..]);
147 return payload;
148 };
149 let slot_content_end = slot_content_start + slot_end_rel;
150 let slot_content = &children_html[slot_content_start..slot_content_end];
151
152 payload
153 .named_html
154 .entry(slot_name)
155 .or_default()
156 .push_str(slot_content);
157
158 cursor = slot_content_end + SLOT_END_MARKER.len();
159 }
160
161 payload.default_html.push_str(&children_html[cursor..]);
162 payload
163}
164
165fn encode_slot_name(name: &str) -> String {
166 let mut out = String::with_capacity(name.len() * 2);
167 for byte in name.as_bytes() {
168 let _ = write!(&mut out, "{byte:02x}");
169 }
170 out
171}
172
173fn decode_slot_name(encoded_name: &str) -> Option<String> {
174 if encoded_name.is_empty() || encoded_name.len() % 2 != 0 {
175 return None;
176 }
177
178 let mut bytes = Vec::with_capacity(encoded_name.len() / 2);
179 for chunk in encoded_name.as_bytes().chunks_exact(2) {
180 let chunk = std::str::from_utf8(chunk).ok()?;
181 let byte = u8::from_str_radix(chunk, 16).ok()?;
182 bytes.push(byte);
183 }
184
185 String::from_utf8(bytes).ok()
186}