Skip to main content

maud_extensions_runtime/
lib.rs

1use 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}