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::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
47/// A renderable wrapper that assigns content to a named slot.
48///
49/// Most code should reach this type through [`InSlotExt::in_slot`] instead of constructing it
50/// directly.
51pub struct Slotted<T: Render> {
52    value: T,
53    slot_name: String,
54}
55
56impl<T: Render> Slotted<T> {
57    /// Creates a slotted wrapper around a renderable value.
58    #[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
91/// Extension trait for assigning children to a named slot.
92pub trait InSlotExt: Render + Sized {
93    /// Tags the rendered value for `named_slot(slot_name)`.
94    ///
95    /// Slot names are opaque strings. Duplicate names are concatenated in render order.
96    #[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
104/// A renderable wrapper that attaches child markup to a component before rendering it.
105///
106/// This is the transport hook that feeds `.with_children(...)` into the runtime
107/// slot stack read by [`slot`] and [`named_slot`].
108///
109/// Most code should reach this type through [`WithChildrenExt::with_children`] instead of
110/// constructing it directly.
111pub struct SlottedComponent<T: Render> {
112    component: T,
113    children_html: String,
114}
115
116impl<T: Render> SlottedComponent<T> {
117    /// Creates a slotted component wrapper around a component and its children.
118    #[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
148/// Extension trait for attaching slot-aware child markup to a renderable component.
149///
150/// This is the lower-level transport surface for runtime slots. Prefer
151/// `ComponentBuilder` from `maud-extensions` for new typed shell/layout
152/// components when the regions can be expressed as fields.
153pub trait WithChildrenExt: Render + Sized {
154    /// Renders `children` into the slot transport expected by [`slot`] and [`named_slot`].
155    #[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
163/// Common imports for runtime slot-based component composition.
164pub mod prelude {
165    pub use crate::{InSlotExt, WithChildrenExt, named_slot, slot};
166}
167
168/// Renders the default slot for the current slotted component context.
169///
170/// Outside `.with_children(...)`, this returns empty markup.
171#[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/// Renders a named slot for the current slotted component context.
179///
180/// Duplicate slot names are concatenated in render order. Outside `.with_children(...)`, this
181/// returns empty markup.
182#[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}