Skip to main content

maud_extensions_runtime/
lib.rs

1#![forbid(unsafe_code)]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4//! Runtime slot helpers for `maud-extensions`.
5//!
6//! This crate owns the lower-level string-based slot transport used by
7//! `.with_children(...)`, `.in_slot(...)`, `slot()`, and `named_slot()`.
8//!
9//! Prefer `#[derive(ComponentBuilder)]` from `maud-extensions` for new shell
10//! and layout components when the content regions can be expressed as typed
11//! fields. Use this crate when you genuinely need open caller-owned child
12//! markup, or when you are keeping an existing slot-based component.
13//!
14//! Support policy:
15//! - MSRV: Rust 1.85
16//! - Supported Maud version: 0.27
17//!
18//! The runtime keeps slot parsing conservative. Malformed transport markers fail closed and the
19//! original HTML is preserved as default slot content instead of being partially consumed.
20
21use 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
62/// A renderable wrapper that assigns content to a named slot.
63///
64/// Most code should reach this type through [`InSlotExt::in_slot`] instead of constructing it
65/// directly.
66pub struct Slotted<T: Render> {
67    value: T,
68    slot_name: String,
69}
70
71impl<T: Render> Slotted<T> {
72    /// Creates a slotted wrapper around a renderable value.
73    #[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
109/// Extension trait for assigning children to a named slot.
110pub trait InSlotExt: Render + Sized {
111    /// Tags the rendered value for `named_slot(slot_name)`.
112    ///
113    /// Slot names are opaque strings. Duplicate names are concatenated in render order.
114    #[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
122/// A renderable wrapper that attaches child markup to a component before rendering it.
123///
124/// This is the transport hook that feeds `.with_children(...)` into the runtime
125/// slot stack read by [`slot`] and [`named_slot`].
126///
127/// Most code should reach this type through [`WithChildrenExt::with_children`] instead of
128/// constructing it directly.
129pub struct SlottedComponent<T: Render> {
130    component: T,
131    children_html: String,
132}
133
134impl<T: Render> SlottedComponent<T> {
135    /// Creates a slotted component wrapper around a component and its children.
136    #[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
166/// Extension trait for attaching slot-aware child markup to a renderable component.
167///
168/// This is the lower-level transport surface for runtime slots. Prefer
169/// `ComponentBuilder` from `maud-extensions` for new typed shell/layout
170/// components when the regions can be expressed as fields.
171pub trait WithChildrenExt: Render + Sized {
172    /// Renders `children` into the slot transport expected by [`slot`] and [`named_slot`].
173    #[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
181/// Common imports for runtime slot-based component composition.
182pub mod prelude {
183    pub use crate::{InSlotExt, WithChildrenExt, named_slot, slot};
184}
185
186/// Renders the default slot for the current slotted component context.
187///
188/// Outside `.with_children(...)`, this returns empty markup.
189#[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/// Renders a named slot for the current slotted component context.
197///
198/// Duplicate slot names are concatenated in render order. Outside `.with_children(...)`, this
199/// returns empty markup.
200#[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}