Skip to main content

topcoat_view/
view.rs

1use core::fmt;
2use std::borrow::Cow;
3
4use smallvec::SmallVec;
5use topcoat_core::context::Cx;
6
7use crate::{Formatter, HtmlContext, HtmlWriter};
8
9/// A self-contained piece of HTML content.
10///
11/// A view may contain multiple sibling nodes, but opened tags must be closed
12/// so the fragment can be nested safely inside a larger document.
13///
14/// ```html
15/// <!-- Valid: all tags are closed, safe to nest -->
16/// <div>Hello</div>
17/// <p>World</p>
18///
19/// <!-- Invalid: unclosed tag would corrupt the parent document -->
20/// <div>Hello
21/// ```
22#[derive(Debug, Default, Clone)]
23pub struct View {
24    part: ViewPart,
25}
26
27impl View {
28    /// Creates a view from accumulated view parts.
29    ///
30    /// This is called by generated `view!` code after collecting the nodes
31    /// and attributes for a fragment.
32    #[doc(hidden)]
33    #[inline]
34    #[must_use]
35    pub fn new(parts: ViewParts) -> Self {
36        Self { part: parts.into() }
37    }
38
39    /// Returns a `View` that renders to an empty string.
40    #[inline]
41    #[must_use]
42    pub fn empty() -> Self {
43        Self::default()
44    }
45
46    /// Creates a view from a `&'static str` without escaping it and without checking for syntax
47    /// errors.
48    #[inline]
49    #[must_use]
50    pub const fn unescaped_unchecked(body: &'static str) -> Self {
51        Self {
52            part: ViewPart::unescaped(body),
53        }
54    }
55
56    /// Renders the view into an HTML string.
57    pub fn render(&self, cx: &Cx) -> String {
58        let mut buf = String::with_capacity(self.part.size_hint());
59        let mut f = Formatter::new(&mut buf);
60        self.part.render(cx, &mut f);
61        buf
62    }
63
64    /// Unwraps the view into its root part.
65    #[inline]
66    pub(crate) fn into_part(self) -> ViewPart {
67        self.part
68    }
69}
70
71/// A renderable value stored in a [`View`].
72///
73/// View parts are created through a [`PartsWriter`] or the `view!` macro. A
74/// part that holds text also records the [`HtmlContext`] it was written for,
75/// so rendering escapes or validates it for exactly that position.
76#[derive(Debug, Default, Clone)]
77#[non_exhaustive]
78pub enum ViewPart {
79    /// Renders no content.
80    #[default]
81    Empty,
82    /// A boolean rendered as text.
83    #[non_exhaustive]
84    Bool(bool),
85    /// An `i8` rendered as text.
86    #[non_exhaustive]
87    I8(i8),
88    /// An `i16` rendered as text.
89    #[non_exhaustive]
90    I16(i16),
91    /// An `i32` rendered as text.
92    #[non_exhaustive]
93    I32(i32),
94    /// An `i64` rendered as text.
95    #[non_exhaustive]
96    I64(i64),
97    /// An `i128` rendered as text.
98    #[non_exhaustive]
99    I128(i128),
100    /// An `isize` rendered as text.
101    #[non_exhaustive]
102    Isize(isize),
103    /// A `u8` rendered as text.
104    #[non_exhaustive]
105    U8(u8),
106    /// A `u16` rendered as text.
107    #[non_exhaustive]
108    U16(u16),
109    /// A `u32` rendered as text.
110    #[non_exhaustive]
111    U32(u32),
112    /// A `u64` rendered as text.
113    #[non_exhaustive]
114    U64(u64),
115    /// A `u128` rendered as text.
116    #[non_exhaustive]
117    U128(u128),
118    /// A `usize` rendered as text.
119    #[non_exhaustive]
120    Usize(usize),
121    /// An `f32` rendered as text.
122    #[non_exhaustive]
123    F32(f32),
124    /// An `f64` rendered as text.
125    #[non_exhaustive]
126    F64(f64),
127    /// A character rendered for the recorded context.
128    #[non_exhaustive]
129    Char { value: char, context: HtmlContext },
130    /// A string rendered for the recorded context.
131    #[non_exhaustive]
132    Str {
133        value: Cow<'static, str>,
134        context: HtmlContext,
135    },
136    /// A custom view part that writes its output at render time.
137    #[non_exhaustive]
138    BoxDyn {
139        inner: Box<dyn DynViewPart>,
140        context: HtmlContext,
141        size_hint: usize,
142    },
143    /// A sequence of view parts rendered in order.
144    #[non_exhaustive]
145    BoxSlice {
146        inner: Box<[ViewPart]>,
147        size_hint: usize,
148    },
149}
150
151impl ViewPart {
152    /// Returns an empty view part.
153    #[inline]
154    #[must_use]
155    pub fn empty() -> Self {
156        Self::Empty
157    }
158
159    /// Returns `true` if the view part is [`Empty`].
160    ///
161    /// [`Empty`]: ViewPart::Empty
162    #[must_use]
163    pub fn is_empty(&self) -> bool {
164        matches!(self, Self::Empty)
165    }
166
167    /// Returns a part that renders `value` verbatim.
168    #[inline]
169    pub(crate) const fn unescaped(value: &'static str) -> Self {
170        Self::Str {
171            value: Cow::Borrowed(value),
172            context: HtmlContext::Unescaped,
173        }
174    }
175
176    /// Writes the part into `f`, escaped or validated for the context each
177    /// piece of text was written in.
178    pub(crate) fn render(&self, cx: &Cx, f: &mut Formatter<'_>) {
179        let mut int_buffer = itoa::Buffer::new();
180        let mut float_buffer = zmij::Buffer::new();
181
182        match self {
183            Self::Empty => {}
184            Self::Bool(inner) => f.write_str(if *inner { "true" } else { "false" }),
185            // The `Display` output of the numeric types consists of digits,
186            // signs, and plain letters, none of which are significant in any
187            // HTML context, so they write verbatim.
188            Self::I8(inner) => f.write_str(int_buffer.format(*inner)),
189            Self::I16(inner) => f.write_str(int_buffer.format(*inner)),
190            Self::I32(inner) => f.write_str(int_buffer.format(*inner)),
191            Self::I64(inner) => f.write_str(int_buffer.format(*inner)),
192            Self::I128(inner) => f.write_str(int_buffer.format(*inner)),
193            Self::Isize(inner) => f.write_str(int_buffer.format(*inner)),
194            Self::U8(inner) => f.write_str(int_buffer.format(*inner)),
195            Self::U16(inner) => f.write_str(int_buffer.format(*inner)),
196            Self::U32(inner) => f.write_str(int_buffer.format(*inner)),
197            Self::U64(inner) => f.write_str(int_buffer.format(*inner)),
198            Self::U128(inner) => f.write_str(int_buffer.format(*inner)),
199            Self::Usize(inner) => f.write_str(int_buffer.format(*inner)),
200            Self::F32(inner) => f.write_str(float_buffer.format(*inner)),
201            Self::F64(inner) => f.write_str(float_buffer.format(*inner)),
202            Self::Char { value, context } => context.writer(f).write_char(*value),
203            Self::Str { value, context } => context.writer(f).write_str(value),
204            Self::BoxDyn { inner, context, .. } => inner.render(cx, &mut context.writer(f)),
205            Self::BoxSlice { inner, .. } => {
206                for part in inner {
207                    part.render(cx, f);
208                }
209            }
210        }
211    }
212
213    /// Returns an estimate of the number of bytes this part will write.
214    ///
215    /// Used to pre-allocate the output buffer. A slight over-estimate is
216    /// preferable to an under-estimate: falling short forces the buffer to
217    /// grow and copy, whereas a modest over-estimate only leaves a little
218    /// capacity unused.
219    pub(crate) fn size_hint(&self) -> usize {
220        // Each numeric hint is the midpoint, rounded up, between the shortest
221        // and widest output the type can `Display`, including the leading `-`
222        // for signed types (`isize`/`usize` assume a 64-bit target). A
223        // float's `Display` width is unbounded for extreme magnitudes, so the
224        // upper end is the shortest round-trip form of a typical value.
225        #[allow(clippy::match_same_arms)]
226        match self {
227            Self::Empty => 0,
228            Self::Bool(_) => 5,
229            Self::I8(_) => 3,
230            Self::I16(_) => 4,
231            Self::I32(_) => 6,
232            Self::I64(_) => 11,
233            Self::I128(_) => 21,
234            Self::Isize(_) => 11,
235            Self::U8(_) => 2,
236            Self::U16(_) => 3,
237            Self::U32(_) => 6,
238            Self::U64(_) => 11,
239            Self::U128(_) => 20,
240            Self::Usize(_) => 11,
241            Self::F32(_) => 9,
242            Self::F64(_) => 13,
243            // One to four UTF-8 bytes, or an escape sequence.
244            Self::Char { .. } => 3,
245            Self::Str { value, context } => match context {
246                HtmlContext::Unescaped => value.len(),
247                // Assume some characters escape into multi-byte sequences.
248                _ => value.len() + value.len() / 8,
249            },
250            Self::BoxDyn { size_hint, .. } | Self::BoxSlice { size_hint, .. } => *size_hint,
251        }
252    }
253}
254
255/// A boxed view part that writes its output at render time.
256///
257/// Implement this for values whose output is only known when the view
258/// renders, such as resolved asset URLs. The writer passed to
259/// [`render`](Self::render) already carries the [`HtmlContext`] of the
260/// position the part was pushed into, so everything written through it is
261/// escaped or validated for that position.
262pub trait DynViewPart: 'static + fmt::Debug + Send {
263    /// Writes this part's output into `w`.
264    fn render(&self, cx: &Cx, w: &mut HtmlWriter<'_, '_>);
265
266    /// Returns an estimate of the number of bytes this part will write.
267    ///
268    /// Used to pre-allocate the output buffer, so aim for a close estimate. A
269    /// slight over-estimate is usually preferable to an under-estimate.
270    #[inline]
271    fn size_hint(&self) -> usize {
272        0
273    }
274
275    /// Clones this view part into a fresh boxed value.
276    fn clone_box(&self) -> Box<dyn DynViewPart>;
277}
278
279impl Clone for Box<dyn DynViewPart> {
280    #[inline]
281    fn clone(&self) -> Self {
282        (**self).clone_box()
283    }
284}
285
286/// A buffer collecting renderable values before they become a [`View`].
287///
288/// This is plumbing for generated `view!` code, which fills the buffer
289/// through [`PartsWriter`] lenses and the position helpers and finally passes
290/// it to `View::new`.
291#[doc(hidden)]
292#[derive(Debug, Default, Clone)]
293pub struct ViewParts {
294    items: SmallVec<[ViewPart; 8]>,
295}
296
297impl ViewParts {
298    /// Creates an empty view-parts buffer.
299    #[inline]
300    #[must_use]
301    pub fn new() -> Self {
302        Self::default()
303    }
304
305    /// Appends a nested view, such as a rendered component.
306    #[inline]
307    pub fn push_view(&mut self, view: View) -> &mut Self {
308        self.items.push(view.into_part());
309        self
310    }
311
312    /// Appends an already-sealed view part.
313    ///
314    /// A part records the [`HtmlContext`] its text was written for. Pushing
315    /// it into a position with different escaping requirements bypasses that
316    /// protection, so this is reserved for framework plumbing that re-emits
317    /// parts in the position family they were built for.
318    #[doc(hidden)]
319    #[inline]
320    pub fn push_part(&mut self, part: ViewPart) -> &mut Self {
321        self.items.push(part);
322        self
323    }
324}
325
326impl From<ViewParts> for ViewPart {
327    #[inline]
328    fn from(mut value: ViewParts) -> Self {
329        match value.items.len() {
330            0 => ViewPart::Empty,
331            1 => value.items.pop().unwrap(),
332            _ => {
333                let size_hint = value.items.iter().map(ViewPart::size_hint).sum();
334                ViewPart::BoxSlice {
335                    inner: value.items.into_boxed_slice(),
336                    size_hint,
337                }
338            }
339        }
340    }
341}
342
343macro_rules! impl_push_primitive {
344    ($method:ident, $ty:ty, $variant:ident) => {
345        #[doc = concat!("Appends a `", stringify!($ty), "` rendered as text.")]
346        ///
347        /// Its rendered form contains no character that is significant in any
348        /// HTML context, so no escaping applies.
349        #[inline]
350        pub fn $method(&mut self, value: $ty) -> &mut Self {
351            self.parts.items.push(ViewPart::$variant(value));
352            self
353        }
354    };
355}
356
357/// A context-carrying writer over a view-parts buffer, created per position.
358///
359/// The `view!` macro creates a `PartsWriter` for each dynamic position it
360/// fills and hands it to the matching position trait:
361/// [`NodeViewParts`](crate::NodeViewParts),
362/// [`AttributeValueViewParts`](crate::AttributeValueViewParts),
363/// [`AttributeKeyViewParts`](crate::AttributeKeyViewParts),
364/// [`ElementNameViewParts`](crate::ElementNameViewParts), or
365/// [`AttributeViewParts`](crate::AttributeViewParts).
366///
367/// Implementations of those traits make a value renderable by pushing it
368/// through the `push_*` methods, which seal the pushed text with the
369/// [`HtmlContext`] of the position so rendering escapes or validates it
370/// correctly, or by delegating to another implementation of the same
371/// position trait. [`push_str_unescaped`](Self::push_str_unescaped) is the
372/// only way to opt out of that protection.
373pub struct PartsWriter<'a> {
374    parts: &'a mut ViewParts,
375    context: HtmlContext,
376}
377
378impl<'a> PartsWriter<'a> {
379    /// Creates a writer that seals everything pushed into it with `context`.
380    #[inline]
381    pub fn new(parts: &'a mut ViewParts, context: HtmlContext) -> Self {
382        Self { parts, context }
383    }
384
385    /// Returns a writer over the same buffer for a different context.
386    ///
387    /// This is how in-crate compositions such as
388    /// [`Attribute`](crate::Attribute) transition between the
389    /// positions they span.
390    #[inline]
391    pub(crate) fn with_context(&mut self, context: HtmlContext) -> PartsWriter<'_> {
392        PartsWriter {
393            parts: self.parts,
394            context,
395        }
396    }
397
398    /// Appends a string, sealed with this writer's context.
399    #[inline]
400    pub fn push_str(&mut self, value: impl Into<Cow<'static, str>>) -> &mut Self {
401        self.parts.items.push(ViewPart::Str {
402            value: value.into(),
403            context: self.context,
404        });
405        self
406    }
407
408    /// Appends a string that renders verbatim, bypassing this writer's
409    /// context.
410    ///
411    /// Use this only for trusted markup. Passing untrusted input defeats the
412    /// runtime's escaping and can lead to XSS vulnerabilities.
413    #[inline]
414    pub fn push_str_unescaped(&mut self, value: impl Into<Cow<'static, str>>) -> &mut Self {
415        self.parts.items.push(ViewPart::Str {
416            value: value.into(),
417            context: HtmlContext::Unescaped,
418        });
419        self
420    }
421
422    /// Appends a character, sealed with this writer's context.
423    #[inline]
424    pub fn push_char(&mut self, value: char) -> &mut Self {
425        self.parts.items.push(ViewPart::Char {
426            value,
427            context: self.context,
428        });
429        self
430    }
431
432    impl_push_primitive!(push_bool, bool, Bool);
433    impl_push_primitive!(push_i8, i8, I8);
434    impl_push_primitive!(push_i16, i16, I16);
435    impl_push_primitive!(push_i32, i32, I32);
436    impl_push_primitive!(push_i64, i64, I64);
437    impl_push_primitive!(push_i128, i128, I128);
438    impl_push_primitive!(push_isize, isize, Isize);
439    impl_push_primitive!(push_u8, u8, U8);
440    impl_push_primitive!(push_u16, u16, U16);
441    impl_push_primitive!(push_u32, u32, U32);
442    impl_push_primitive!(push_u64, u64, U64);
443    impl_push_primitive!(push_u128, u128, U128);
444    impl_push_primitive!(push_usize, usize, Usize);
445    impl_push_primitive!(push_f32, f32, F32);
446    impl_push_primitive!(push_f64, f64, F64);
447
448    /// Appends a part that writes its output at render time, sealed with
449    /// this writer's context.
450    #[inline]
451    pub fn push_dyn(&mut self, part: Box<dyn DynViewPart>) -> &mut Self {
452        self.parts.items.push(ViewPart::BoxDyn {
453            size_hint: part.size_hint(),
454            inner: part,
455            context: self.context,
456        });
457        self
458    }
459
460    /// Appends an already-sealed view part.
461    ///
462    /// A part records the [`HtmlContext`] its text was written for; this
463    /// writer's context does not apply. See [`ViewParts::push_part`].
464    #[doc(hidden)]
465    #[inline]
466    pub fn push_part(&mut self, part: ViewPart) -> &mut Self {
467        self.parts.items.push(part);
468        self
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    fn render(build: impl FnOnce(&mut ViewParts)) -> String {
477        let mut parts = ViewParts::new();
478        build(&mut parts);
479        View::new(parts).render(&Cx::default())
480    }
481
482    #[test]
483    fn empty_view_renders_empty() {
484        assert_eq!(View::empty().render(&Cx::default()), "");
485    }
486
487    #[test]
488    fn unescaped_unchecked_renders_verbatim() {
489        let view = View::unescaped_unchecked("<b>raw</b>");
490        assert_eq!(view.render(&Cx::default()), "<b>raw</b>");
491    }
492
493    #[test]
494    fn push_str_seals_the_writer_context() {
495        let out = render(|parts| {
496            PartsWriter::new(parts, HtmlContext::Text).push_str("<b> & \"q\"");
497        });
498        assert_eq!(out, "&lt;b&gt; &amp; \"q\"");
499
500        let out = render(|parts| {
501            PartsWriter::new(parts, HtmlContext::AttributeValue).push_str("<b> & \"q\"");
502        });
503        assert_eq!(out, "<b> &amp; &quot;q&quot;");
504    }
505
506    #[test]
507    fn push_str_unescaped_bypasses_the_context() {
508        let out = render(|parts| {
509            PartsWriter::new(parts, HtmlContext::Text).push_str_unescaped("<b>raw</b>");
510        });
511        assert_eq!(out, "<b>raw</b>");
512    }
513
514    #[test]
515    fn push_char_seals_the_writer_context() {
516        let out = render(|parts| {
517            PartsWriter::new(parts, HtmlContext::Text).push_char('<');
518        });
519        assert_eq!(out, "&lt;");
520    }
521
522    #[test]
523    #[should_panic(expected = "invalid attribute key")]
524    fn ident_context_panics_on_forbidden_characters_at_render() {
525        render(|parts| {
526            PartsWriter::new(parts, HtmlContext::AttributeKey).push_str("on click");
527        });
528    }
529
530    #[test]
531    fn push_primitives_render_as_text() {
532        let out = render(|parts| {
533            let mut writer = PartsWriter::new(parts, HtmlContext::Text);
534            writer.push_i32(-42).push_str_unescaped(" ");
535            writer.push_bool(true).push_str_unescaped(" ");
536            writer.push_f64(1.5);
537        });
538        assert_eq!(out, "-42 true 1.5");
539    }
540
541    #[test]
542    fn push_view_splices_nested_views() {
543        let mut inner_parts = ViewParts::new();
544        PartsWriter::new(&mut inner_parts, HtmlContext::Text).push_str("a < b");
545        let inner = View::new(inner_parts);
546
547        let out = render(|parts| {
548            PartsWriter::new(parts, HtmlContext::Unescaped).push_str("<p>");
549            parts.push_view(inner);
550            PartsWriter::new(parts, HtmlContext::Unescaped).push_str("</p>");
551        });
552        assert_eq!(out, "<p>a &lt; b</p>");
553    }
554
555    #[test]
556    fn size_hint_is_exact_for_unescaped_strings() {
557        let view = View::unescaped_unchecked("<b>raw</b>");
558        assert_eq!(view.part.size_hint(), 10);
559    }
560}