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        use std::fmt::Write;
180
181        match self {
182            Self::Empty => {}
183            Self::Bool(inner) => f.write_str(if *inner { "true" } else { "false" }),
184            // The `Display` output of the numeric types consists of digits,
185            // signs, and plain letters, none of which are significant in any
186            // HTML context, so they write verbatim.
187            Self::I8(inner) => {
188                let _ = write!(f, "{inner}");
189            }
190            Self::I16(inner) => {
191                let _ = write!(f, "{inner}");
192            }
193            Self::I32(inner) => {
194                let _ = write!(f, "{inner}");
195            }
196            Self::I64(inner) => {
197                let _ = write!(f, "{inner}");
198            }
199            Self::I128(inner) => {
200                let _ = write!(f, "{inner}");
201            }
202            Self::Isize(inner) => {
203                let _ = write!(f, "{inner}");
204            }
205            Self::U8(inner) => {
206                let _ = write!(f, "{inner}");
207            }
208            Self::U16(inner) => {
209                let _ = write!(f, "{inner}");
210            }
211            Self::U32(inner) => {
212                let _ = write!(f, "{inner}");
213            }
214            Self::U64(inner) => {
215                let _ = write!(f, "{inner}");
216            }
217            Self::U128(inner) => {
218                let _ = write!(f, "{inner}");
219            }
220            Self::Usize(inner) => {
221                let _ = write!(f, "{inner}");
222            }
223            Self::F32(inner) => {
224                let _ = write!(f, "{inner}");
225            }
226            Self::F64(inner) => {
227                let _ = write!(f, "{inner}");
228            }
229            Self::Char { value, context } => context.writer(f).write_char(*value),
230            Self::Str { value, context } => context.writer(f).write_str(value),
231            Self::BoxDyn { inner, context, .. } => inner.render(cx, &mut context.writer(f)),
232            Self::BoxSlice { inner, .. } => {
233                for part in inner {
234                    part.render(cx, f);
235                }
236            }
237        }
238    }
239
240    /// Returns an estimate of the number of bytes this part will write.
241    ///
242    /// Used to pre-allocate the output buffer. A slight over-estimate is
243    /// preferable to an under-estimate: falling short forces the buffer to
244    /// grow and copy, whereas a modest over-estimate only leaves a little
245    /// capacity unused.
246    pub(crate) fn size_hint(&self) -> usize {
247        // Each numeric hint is the midpoint, rounded up, between the shortest
248        // and widest output the type can `Display`, including the leading `-`
249        // for signed types (`isize`/`usize` assume a 64-bit target). A
250        // float's `Display` width is unbounded for extreme magnitudes, so the
251        // upper end is the shortest round-trip form of a typical value.
252        #[allow(clippy::match_same_arms)]
253        match self {
254            Self::Empty => 0,
255            Self::Bool(_) => 5,
256            Self::I8(_) => 3,
257            Self::I16(_) => 4,
258            Self::I32(_) => 6,
259            Self::I64(_) => 11,
260            Self::I128(_) => 21,
261            Self::Isize(_) => 11,
262            Self::U8(_) => 2,
263            Self::U16(_) => 3,
264            Self::U32(_) => 6,
265            Self::U64(_) => 11,
266            Self::U128(_) => 20,
267            Self::Usize(_) => 11,
268            Self::F32(_) => 9,
269            Self::F64(_) => 13,
270            // One to four UTF-8 bytes, or an escape sequence.
271            Self::Char { .. } => 3,
272            Self::Str { value, context } => match context {
273                HtmlContext::Unescaped => value.len(),
274                // Assume some characters escape into multi-byte sequences.
275                _ => value.len() + value.len() / 8,
276            },
277            Self::BoxDyn { size_hint, .. } | Self::BoxSlice { size_hint, .. } => *size_hint,
278        }
279    }
280}
281
282/// A boxed view part that writes its output at render time.
283///
284/// Implement this for values whose output is only known when the view
285/// renders, such as resolved asset URLs. The writer passed to
286/// [`render`](Self::render) already carries the [`HtmlContext`] of the
287/// position the part was pushed into, so everything written through it is
288/// escaped or validated for that position.
289pub trait DynViewPart: 'static + fmt::Debug + Send {
290    /// Writes this part's output into `w`.
291    fn render(&self, cx: &Cx, w: &mut HtmlWriter<'_, '_>);
292
293    /// Returns an estimate of the number of bytes this part will write.
294    ///
295    /// Used to pre-allocate the output buffer, so aim for a close estimate. A
296    /// slight over-estimate is usually preferable to an under-estimate.
297    #[inline]
298    fn size_hint(&self) -> usize {
299        0
300    }
301
302    /// Clones this view part into a fresh boxed value.
303    fn clone_box(&self) -> Box<dyn DynViewPart>;
304}
305
306impl Clone for Box<dyn DynViewPart> {
307    #[inline]
308    fn clone(&self) -> Self {
309        (**self).clone_box()
310    }
311}
312
313/// A buffer collecting renderable values before they become a [`View`].
314///
315/// This is plumbing for generated `view!` code, which fills the buffer
316/// through [`PartsWriter`] lenses and the position helpers and finally passes
317/// it to `View::new`.
318#[doc(hidden)]
319#[derive(Debug, Default, Clone)]
320pub struct ViewParts {
321    items: SmallVec<[ViewPart; 8]>,
322}
323
324impl ViewParts {
325    /// Creates an empty view-parts buffer.
326    #[inline]
327    #[must_use]
328    pub fn new() -> Self {
329        Self::default()
330    }
331
332    /// Appends a nested view, such as a rendered component.
333    #[inline]
334    pub fn push_view(&mut self, view: View) -> &mut Self {
335        self.items.push(view.into_part());
336        self
337    }
338
339    /// Appends an already-sealed view part.
340    ///
341    /// A part records the [`HtmlContext`] its text was written for. Pushing
342    /// it into a position with different escaping requirements bypasses that
343    /// protection, so this is reserved for framework plumbing that re-emits
344    /// parts in the position family they were built for.
345    #[doc(hidden)]
346    #[inline]
347    pub fn push_part(&mut self, part: ViewPart) -> &mut Self {
348        self.items.push(part);
349        self
350    }
351}
352
353impl From<ViewParts> for ViewPart {
354    #[inline]
355    fn from(mut value: ViewParts) -> Self {
356        match value.items.len() {
357            0 => ViewPart::Empty,
358            1 => value.items.pop().unwrap(),
359            _ => {
360                let size_hint = value.items.iter().map(ViewPart::size_hint).sum();
361                ViewPart::BoxSlice {
362                    inner: value.items.into_boxed_slice(),
363                    size_hint,
364                }
365            }
366        }
367    }
368}
369
370macro_rules! impl_push_primitive {
371    ($method:ident, $ty:ty, $variant:ident) => {
372        #[doc = concat!("Appends a `", stringify!($ty), "` rendered as text.")]
373        ///
374        /// Its rendered form contains no character that is significant in any
375        /// HTML context, so no escaping applies.
376        #[inline]
377        pub fn $method(&mut self, value: $ty) -> &mut Self {
378            self.parts.items.push(ViewPart::$variant(value));
379            self
380        }
381    };
382}
383
384/// A context-carrying writer over a view-parts buffer, created per position.
385///
386/// The `view!` macro creates a `PartsWriter` for each dynamic position it
387/// fills and hands it to the matching position trait:
388/// [`NodeViewParts`](crate::NodeViewParts),
389/// [`AttributeValueViewParts`](crate::AttributeValueViewParts),
390/// [`AttributeKeyViewParts`](crate::AttributeKeyViewParts),
391/// [`ElementNameViewParts`](crate::ElementNameViewParts), or
392/// [`AttributeViewParts`](crate::AttributeViewParts).
393///
394/// Implementations of those traits make a value renderable by pushing it
395/// through the `push_*` methods, which seal the pushed text with the
396/// [`HtmlContext`] of the position so rendering escapes or validates it
397/// correctly, or by delegating to another implementation of the same
398/// position trait. [`push_str_unescaped`](Self::push_str_unescaped) is the
399/// only way to opt out of that protection.
400pub struct PartsWriter<'a> {
401    parts: &'a mut ViewParts,
402    context: HtmlContext,
403}
404
405impl<'a> PartsWriter<'a> {
406    /// Creates a writer that seals everything pushed into it with `context`.
407    #[inline]
408    pub fn new(parts: &'a mut ViewParts, context: HtmlContext) -> Self {
409        Self { parts, context }
410    }
411
412    /// Returns a writer over the same buffer for a different context.
413    ///
414    /// This is how in-crate compositions such as
415    /// [`Attribute`](crate::Attribute) transition between the
416    /// positions they span.
417    #[inline]
418    pub(crate) fn with_context(&mut self, context: HtmlContext) -> PartsWriter<'_> {
419        PartsWriter {
420            parts: self.parts,
421            context,
422        }
423    }
424
425    /// Appends a string, sealed with this writer's context.
426    #[inline]
427    pub fn push_str(&mut self, value: impl Into<Cow<'static, str>>) -> &mut Self {
428        self.parts.items.push(ViewPart::Str {
429            value: value.into(),
430            context: self.context,
431        });
432        self
433    }
434
435    /// Appends a string that renders verbatim, bypassing this writer's
436    /// context.
437    ///
438    /// Use this only for trusted markup. Passing untrusted input defeats the
439    /// runtime's escaping and can lead to XSS vulnerabilities.
440    #[inline]
441    pub fn push_str_unescaped(&mut self, value: impl Into<Cow<'static, str>>) -> &mut Self {
442        self.parts.items.push(ViewPart::Str {
443            value: value.into(),
444            context: HtmlContext::Unescaped,
445        });
446        self
447    }
448
449    /// Appends a character, sealed with this writer's context.
450    #[inline]
451    pub fn push_char(&mut self, value: char) -> &mut Self {
452        self.parts.items.push(ViewPart::Char {
453            value,
454            context: self.context,
455        });
456        self
457    }
458
459    impl_push_primitive!(push_bool, bool, Bool);
460    impl_push_primitive!(push_i8, i8, I8);
461    impl_push_primitive!(push_i16, i16, I16);
462    impl_push_primitive!(push_i32, i32, I32);
463    impl_push_primitive!(push_i64, i64, I64);
464    impl_push_primitive!(push_i128, i128, I128);
465    impl_push_primitive!(push_isize, isize, Isize);
466    impl_push_primitive!(push_u8, u8, U8);
467    impl_push_primitive!(push_u16, u16, U16);
468    impl_push_primitive!(push_u32, u32, U32);
469    impl_push_primitive!(push_u64, u64, U64);
470    impl_push_primitive!(push_u128, u128, U128);
471    impl_push_primitive!(push_usize, usize, Usize);
472    impl_push_primitive!(push_f32, f32, F32);
473    impl_push_primitive!(push_f64, f64, F64);
474
475    /// Appends a part that writes its output at render time, sealed with
476    /// this writer's context.
477    #[inline]
478    pub fn push_dyn(&mut self, part: Box<dyn DynViewPart>) -> &mut Self {
479        self.parts.items.push(ViewPart::BoxDyn {
480            size_hint: part.size_hint(),
481            inner: part,
482            context: self.context,
483        });
484        self
485    }
486
487    /// Appends an already-sealed view part.
488    ///
489    /// A part records the [`HtmlContext`] its text was written for; this
490    /// writer's context does not apply. See [`ViewParts::push_part`].
491    #[doc(hidden)]
492    #[inline]
493    pub fn push_part(&mut self, part: ViewPart) -> &mut Self {
494        self.parts.items.push(part);
495        self
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    fn render(build: impl FnOnce(&mut ViewParts)) -> String {
504        let mut parts = ViewParts::new();
505        build(&mut parts);
506        View::new(parts).render(&Cx::default())
507    }
508
509    #[test]
510    fn empty_view_renders_empty() {
511        assert_eq!(View::empty().render(&Cx::default()), "");
512    }
513
514    #[test]
515    fn unescaped_unchecked_renders_verbatim() {
516        let view = View::unescaped_unchecked("<b>raw</b>");
517        assert_eq!(view.render(&Cx::default()), "<b>raw</b>");
518    }
519
520    #[test]
521    fn push_str_seals_the_writer_context() {
522        let out = render(|parts| {
523            PartsWriter::new(parts, HtmlContext::Text).push_str("<b> & \"q\"");
524        });
525        assert_eq!(out, "&lt;b&gt; &amp; \"q\"");
526
527        let out = render(|parts| {
528            PartsWriter::new(parts, HtmlContext::AttributeValue).push_str("<b> & \"q\"");
529        });
530        assert_eq!(out, "<b> &amp; &quot;q&quot;");
531    }
532
533    #[test]
534    fn push_str_unescaped_bypasses_the_context() {
535        let out = render(|parts| {
536            PartsWriter::new(parts, HtmlContext::Text).push_str_unescaped("<b>raw</b>");
537        });
538        assert_eq!(out, "<b>raw</b>");
539    }
540
541    #[test]
542    fn push_char_seals_the_writer_context() {
543        let out = render(|parts| {
544            PartsWriter::new(parts, HtmlContext::Text).push_char('<');
545        });
546        assert_eq!(out, "&lt;");
547    }
548
549    #[test]
550    #[should_panic(expected = "invalid attribute key")]
551    fn ident_context_panics_on_forbidden_characters_at_render() {
552        render(|parts| {
553            PartsWriter::new(parts, HtmlContext::AttributeKey).push_str("on click");
554        });
555    }
556
557    #[test]
558    fn push_primitives_render_as_text() {
559        let out = render(|parts| {
560            let mut writer = PartsWriter::new(parts, HtmlContext::Text);
561            writer.push_i32(-42).push_str_unescaped(" ");
562            writer.push_bool(true).push_str_unescaped(" ");
563            writer.push_f64(1.5);
564        });
565        assert_eq!(out, "-42 true 1.5");
566    }
567
568    #[test]
569    fn push_view_splices_nested_views() {
570        let mut inner_parts = ViewParts::new();
571        PartsWriter::new(&mut inner_parts, HtmlContext::Text).push_str("a < b");
572        let inner = View::new(inner_parts);
573
574        let out = render(|parts| {
575            PartsWriter::new(parts, HtmlContext::Unescaped).push_str("<p>");
576            parts.push_view(inner);
577            PartsWriter::new(parts, HtmlContext::Unescaped).push_str("</p>");
578        });
579        assert_eq!(out, "<p>a &lt; b</p>");
580    }
581
582    #[test]
583    fn size_hint_is_exact_for_unescaped_strings() {
584        let view = View::unescaped_unchecked("<b>raw</b>");
585        assert_eq!(view.part.size_hint(), 10);
586    }
587}