1use core::fmt;
2use std::borrow::Cow;
3
4#[cfg(feature = "http")]
5use http::{HeaderMap, StatusCode};
6use smallvec::SmallVec;
7use topcoat_core::context::Cx;
8
9use crate::{Formatter, HtmlContext, HtmlWriter};
10
11#[derive(Debug, Default, Clone)]
25pub struct View {
26 part: ViewPart,
27}
28
29impl View {
30 #[doc(hidden)]
35 #[inline]
36 #[must_use]
37 pub fn new(parts: ViewParts) -> Self {
38 Self { part: parts.into() }
39 }
40
41 #[inline]
43 #[must_use]
44 pub fn empty() -> Self {
45 Self::default()
46 }
47
48 #[inline]
51 #[must_use]
52 pub const fn unescaped_unchecked(body: &'static str) -> Self {
53 Self {
54 part: ViewPart::unescaped(body),
55 }
56 }
57
58 #[cfg_attr(
60 feature = "http",
61 doc = "",
62 doc = "Status codes and headers declared in the view are discarded;",
63 doc = "[`render_response`](Self::render_response) collects them."
64 )]
65 pub fn render(&self, cx: &Cx) -> String {
66 let mut buf = String::with_capacity(self.part.size_hint());
67 let mut f = Formatter::new(&mut buf);
68 self.part.render(cx, &mut f);
69 buf
70 }
71
72 #[cfg(feature = "http")]
82 #[must_use]
83 pub fn render_response(&self, cx: &Cx) -> RenderedResponse {
84 let mut html = String::with_capacity(self.part.size_hint());
85 let mut f = Formatter::new(&mut html);
86 self.part.render(cx, &mut f);
87 let (status_code, headers) = f.into_recorded();
88 RenderedResponse {
89 html,
90 status_code,
91 headers,
92 }
93 }
94
95 #[inline]
97 pub(crate) fn into_part(self) -> ViewPart {
98 self.part
99 }
100}
101
102#[cfg(feature = "http")]
107#[derive(Debug)]
108#[non_exhaustive]
109pub struct RenderedResponse {
110 pub html: String,
112 pub status_code: Option<StatusCode>,
114 pub headers: HeaderMap,
119}
120
121#[derive(Debug, Default, Clone)]
127#[non_exhaustive]
128pub enum ViewPart {
129 #[default]
131 Empty,
132 #[non_exhaustive]
134 Bool(bool),
135 #[non_exhaustive]
137 I8(i8),
138 #[non_exhaustive]
140 I16(i16),
141 #[non_exhaustive]
143 I32(i32),
144 #[non_exhaustive]
146 I64(i64),
147 #[non_exhaustive]
149 I128(i128),
150 #[non_exhaustive]
152 Isize(isize),
153 #[non_exhaustive]
155 U8(u8),
156 #[non_exhaustive]
158 U16(u16),
159 #[non_exhaustive]
161 U32(u32),
162 #[non_exhaustive]
164 U64(u64),
165 #[non_exhaustive]
167 U128(u128),
168 #[non_exhaustive]
170 Usize(usize),
171 #[non_exhaustive]
173 F32(f32),
174 #[non_exhaustive]
176 F64(f64),
177 #[non_exhaustive]
179 Char { value: char, context: HtmlContext },
180 #[non_exhaustive]
182 Str {
183 value: Cow<'static, str>,
184 context: HtmlContext,
185 },
186 #[non_exhaustive]
188 BoxDyn {
189 inner: Box<dyn DynViewPart>,
190 context: HtmlContext,
191 size_hint: usize,
192 },
193 #[non_exhaustive]
195 BoxSlice {
196 inner: Box<[ViewPart]>,
197 size_hint: usize,
198 },
199 #[cfg(feature = "http")]
201 #[non_exhaustive]
202 StatusCode(StatusCode),
203 #[cfg(feature = "http")]
205 #[non_exhaustive]
206 Headers(Box<HeaderMap>),
207}
208
209impl ViewPart {
210 #[inline]
212 #[must_use]
213 pub fn empty() -> Self {
214 Self::Empty
215 }
216
217 #[must_use]
221 pub fn is_empty(&self) -> bool {
222 matches!(self, Self::Empty)
223 }
224
225 #[inline]
227 pub(crate) const fn unescaped(value: &'static str) -> Self {
228 Self::Str {
229 value: Cow::Borrowed(value),
230 context: HtmlContext::Unescaped,
231 }
232 }
233
234 pub(crate) fn render(&self, cx: &Cx, f: &mut Formatter<'_>) {
237 let mut int_buffer = itoa::Buffer::new();
238 let mut float_buffer = zmij::Buffer::new();
239
240 match self {
241 Self::Empty => {}
242 Self::Bool(inner) => f.write_str(if *inner { "true" } else { "false" }),
243 Self::I8(inner) => f.write_str(int_buffer.format(*inner)),
247 Self::I16(inner) => f.write_str(int_buffer.format(*inner)),
248 Self::I32(inner) => f.write_str(int_buffer.format(*inner)),
249 Self::I64(inner) => f.write_str(int_buffer.format(*inner)),
250 Self::I128(inner) => f.write_str(int_buffer.format(*inner)),
251 Self::Isize(inner) => f.write_str(int_buffer.format(*inner)),
252 Self::U8(inner) => f.write_str(int_buffer.format(*inner)),
253 Self::U16(inner) => f.write_str(int_buffer.format(*inner)),
254 Self::U32(inner) => f.write_str(int_buffer.format(*inner)),
255 Self::U64(inner) => f.write_str(int_buffer.format(*inner)),
256 Self::U128(inner) => f.write_str(int_buffer.format(*inner)),
257 Self::Usize(inner) => f.write_str(int_buffer.format(*inner)),
258 Self::F32(inner) => f.write_str(float_buffer.format(*inner)),
259 Self::F64(inner) => f.write_str(float_buffer.format(*inner)),
260 Self::Char { value, context } => context.writer(f).write_char(*value),
261 Self::Str { value, context } => context.writer(f).write_str(value),
262 Self::BoxDyn { inner, context, .. } => inner.render(cx, &mut context.writer(f)),
263 Self::BoxSlice { inner, .. } => {
264 for part in inner {
265 part.render(cx, f);
266 }
267 }
268 #[cfg(feature = "http")]
269 Self::StatusCode(status_code) => f.record_status_code(*status_code),
270 #[cfg(feature = "http")]
271 Self::Headers(headers) => f.record_headers(headers),
272 }
273 }
274
275 pub(crate) fn size_hint(&self) -> usize {
282 #[allow(clippy::match_same_arms)]
288 match self {
289 Self::Empty => 0,
290 Self::Bool(_) => 5,
291 Self::I8(_) => 3,
292 Self::I16(_) => 4,
293 Self::I32(_) => 6,
294 Self::I64(_) => 11,
295 Self::I128(_) => 21,
296 Self::Isize(_) => 11,
297 Self::U8(_) => 2,
298 Self::U16(_) => 3,
299 Self::U32(_) => 6,
300 Self::U64(_) => 11,
301 Self::U128(_) => 20,
302 Self::Usize(_) => 11,
303 Self::F32(_) => 9,
304 Self::F64(_) => 13,
305 Self::Char { .. } => 3,
307 Self::Str { value, context } => match context {
308 HtmlContext::Unescaped => value.len(),
309 _ => value.len() + value.len() / 8,
311 },
312 Self::BoxDyn { size_hint, .. } | Self::BoxSlice { size_hint, .. } => *size_hint,
313 #[cfg(feature = "http")]
314 Self::StatusCode(_) | Self::Headers(_) => 0,
315 }
316 }
317}
318
319pub trait DynViewPart: 'static + fmt::Debug + Send {
327 fn render(&self, cx: &Cx, w: &mut HtmlWriter<'_, '_>);
329
330 #[inline]
335 fn size_hint(&self) -> usize {
336 0
337 }
338
339 fn clone_box(&self) -> Box<dyn DynViewPart>;
341}
342
343impl Clone for Box<dyn DynViewPart> {
344 #[inline]
345 fn clone(&self) -> Self {
346 (**self).clone_box()
347 }
348}
349
350#[doc(hidden)]
356#[derive(Debug, Default, Clone)]
357pub struct ViewParts {
358 items: SmallVec<[ViewPart; 8]>,
359}
360
361impl ViewParts {
362 #[inline]
364 #[must_use]
365 pub fn new() -> Self {
366 Self::default()
367 }
368
369 #[inline]
371 pub fn push_view(&mut self, view: View) -> &mut Self {
372 self.items.push(view.into_part());
373 self
374 }
375
376 #[doc(hidden)]
383 #[inline]
384 pub fn push_part(&mut self, part: ViewPart) -> &mut Self {
385 self.items.push(part);
386 self
387 }
388}
389
390impl From<ViewParts> for ViewPart {
391 #[inline]
392 fn from(mut value: ViewParts) -> Self {
393 match value.items.len() {
394 0 => ViewPart::Empty,
395 1 => value.items.pop().unwrap(),
396 _ => {
397 let size_hint = value.items.iter().map(ViewPart::size_hint).sum();
398 ViewPart::BoxSlice {
399 inner: value.items.into_boxed_slice(),
400 size_hint,
401 }
402 }
403 }
404 }
405}
406
407macro_rules! impl_push_primitive {
408 ($method:ident, $ty:ty, $variant:ident) => {
409 #[doc = concat!("Appends a `", stringify!($ty), "` rendered as text.")]
410 #[inline]
414 pub fn $method(&mut self, value: $ty) -> &mut Self {
415 self.parts.items.push(ViewPart::$variant(value));
416 self
417 }
418 };
419}
420
421pub struct PartsWriter<'a> {
438 parts: &'a mut ViewParts,
439 context: HtmlContext,
440}
441
442impl<'a> PartsWriter<'a> {
443 #[inline]
445 pub fn new(parts: &'a mut ViewParts, context: HtmlContext) -> Self {
446 Self { parts, context }
447 }
448
449 #[inline]
455 pub(crate) fn with_context(&mut self, context: HtmlContext) -> PartsWriter<'_> {
456 PartsWriter {
457 parts: self.parts,
458 context,
459 }
460 }
461
462 #[inline]
464 pub fn push_str(&mut self, value: impl Into<Cow<'static, str>>) -> &mut Self {
465 self.parts.items.push(ViewPart::Str {
466 value: value.into(),
467 context: self.context,
468 });
469 self
470 }
471
472 #[inline]
478 pub fn push_str_unescaped(&mut self, value: impl Into<Cow<'static, str>>) -> &mut Self {
479 self.parts.items.push(ViewPart::Str {
480 value: value.into(),
481 context: HtmlContext::Unescaped,
482 });
483 self
484 }
485
486 #[inline]
488 pub fn push_char(&mut self, value: char) -> &mut Self {
489 self.parts.items.push(ViewPart::Char {
490 value,
491 context: self.context,
492 });
493 self
494 }
495
496 impl_push_primitive!(push_bool, bool, Bool);
497 impl_push_primitive!(push_i8, i8, I8);
498 impl_push_primitive!(push_i16, i16, I16);
499 impl_push_primitive!(push_i32, i32, I32);
500 impl_push_primitive!(push_i64, i64, I64);
501 impl_push_primitive!(push_i128, i128, I128);
502 impl_push_primitive!(push_isize, isize, Isize);
503 impl_push_primitive!(push_u8, u8, U8);
504 impl_push_primitive!(push_u16, u16, U16);
505 impl_push_primitive!(push_u32, u32, U32);
506 impl_push_primitive!(push_u64, u64, U64);
507 impl_push_primitive!(push_u128, u128, U128);
508 impl_push_primitive!(push_usize, usize, Usize);
509 impl_push_primitive!(push_f32, f32, F32);
510 impl_push_primitive!(push_f64, f64, F64);
511
512 #[inline]
515 pub fn push_dyn(&mut self, part: Box<dyn DynViewPart>) -> &mut Self {
516 self.parts.items.push(ViewPart::BoxDyn {
517 size_hint: part.size_hint(),
518 inner: part,
519 context: self.context,
520 });
521 self
522 }
523
524 #[doc(hidden)]
529 #[inline]
530 pub fn push_part(&mut self, part: ViewPart) -> &mut Self {
531 self.parts.items.push(part);
532 self
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539
540 fn render(build: impl FnOnce(&mut ViewParts)) -> String {
541 let mut parts = ViewParts::new();
542 build(&mut parts);
543 View::new(parts).render(&Cx::default())
544 }
545
546 #[test]
547 fn empty_view_renders_empty() {
548 assert_eq!(View::empty().render(&Cx::default()), "");
549 }
550
551 #[test]
552 fn unescaped_unchecked_renders_verbatim() {
553 let view = View::unescaped_unchecked("<b>raw</b>");
554 assert_eq!(view.render(&Cx::default()), "<b>raw</b>");
555 }
556
557 #[test]
558 fn push_str_seals_the_writer_context() {
559 let out = render(|parts| {
560 PartsWriter::new(parts, HtmlContext::Text).push_str("<b> & \"q\"");
561 });
562 assert_eq!(out, "<b> & \"q\"");
563
564 let out = render(|parts| {
565 PartsWriter::new(parts, HtmlContext::AttributeValue).push_str("<b> & \"q\"");
566 });
567 assert_eq!(out, "<b> & "q"");
568 }
569
570 #[test]
571 fn push_str_unescaped_bypasses_the_context() {
572 let out = render(|parts| {
573 PartsWriter::new(parts, HtmlContext::Text).push_str_unescaped("<b>raw</b>");
574 });
575 assert_eq!(out, "<b>raw</b>");
576 }
577
578 #[test]
579 fn push_char_seals_the_writer_context() {
580 let out = render(|parts| {
581 PartsWriter::new(parts, HtmlContext::Text).push_char('<');
582 });
583 assert_eq!(out, "<");
584 }
585
586 #[test]
587 #[should_panic(expected = "invalid attribute key")]
588 fn ident_context_panics_on_forbidden_characters_at_render() {
589 render(|parts| {
590 PartsWriter::new(parts, HtmlContext::AttributeKey).push_str("on click");
591 });
592 }
593
594 #[test]
595 fn push_primitives_render_as_text() {
596 let out = render(|parts| {
597 let mut writer = PartsWriter::new(parts, HtmlContext::Text);
598 writer.push_i32(-42).push_str_unescaped(" ");
599 writer.push_bool(true).push_str_unescaped(" ");
600 writer.push_f64(1.5);
601 });
602 assert_eq!(out, "-42 true 1.5");
603 }
604
605 #[test]
606 fn push_view_splices_nested_views() {
607 let mut inner_parts = ViewParts::new();
608 PartsWriter::new(&mut inner_parts, HtmlContext::Text).push_str("a < b");
609 let inner = View::new(inner_parts);
610
611 let out = render(|parts| {
612 PartsWriter::new(parts, HtmlContext::Unescaped).push_str("<p>");
613 parts.push_view(inner);
614 PartsWriter::new(parts, HtmlContext::Unescaped).push_str("</p>");
615 });
616 assert_eq!(out, "<p>a < b</p>");
617 }
618
619 #[test]
620 fn size_hint_is_exact_for_unescaped_strings() {
621 let view = View::unescaped_unchecked("<b>raw</b>");
622 assert_eq!(view.part.size_hint(), 10);
623 }
624
625 #[cfg(feature = "http")]
626 mod response {
627 use http::header::{CACHE_CONTROL, SET_COOKIE};
628 use http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
629
630 use super::*;
631 use crate::NodeViewParts;
632
633 fn push_node(parts: &mut ViewParts, value: impl NodeViewParts) {
634 value.into_view_parts(
635 &Cx::default(),
636 &mut PartsWriter::new(parts, HtmlContext::Text),
637 );
638 }
639
640 fn push_text(parts: &mut ViewParts, text: &'static str) {
641 PartsWriter::new(parts, HtmlContext::Text).push_str(text);
642 }
643
644 #[test]
645 fn status_code_is_recorded_and_renders_nothing() {
646 let mut parts = ViewParts::new();
647 push_text(&mut parts, "a");
648 push_node(&mut parts, StatusCode::NOT_FOUND);
649 push_text(&mut parts, "b");
650
651 let rendered = View::new(parts).render_response(&Cx::default());
652 assert_eq!(rendered.html, "ab");
653 assert_eq!(rendered.status_code, Some(StatusCode::NOT_FOUND));
654 assert!(rendered.headers.is_empty());
655 }
656
657 #[test]
658 fn render_response_without_declarations_is_empty() {
659 let mut parts = ViewParts::new();
660 push_text(&mut parts, "a");
661
662 let rendered = View::new(parts).render_response(&Cx::default());
663 assert_eq!(rendered.html, "a");
664 assert_eq!(rendered.status_code, None);
665 assert!(rendered.headers.is_empty());
666 }
667
668 #[test]
669 fn render_discards_declarations() {
670 let mut parts = ViewParts::new();
671 push_node(&mut parts, StatusCode::NOT_FOUND);
672 push_node(
673 &mut parts,
674 (CACHE_CONTROL, HeaderValue::from_static("no-store")),
675 );
676 push_text(&mut parts, "a");
677
678 assert_eq!(View::new(parts).render(&Cx::default()), "a");
679 }
680
681 #[test]
682 fn first_status_code_wins() {
683 let mut parts = ViewParts::new();
684 push_node(&mut parts, StatusCode::NOT_FOUND);
685 push_node(&mut parts, StatusCode::OK);
686
687 let rendered = View::new(parts).render_response(&Cx::default());
688 assert_eq!(rendered.status_code, Some(StatusCode::NOT_FOUND));
689 }
690
691 #[test]
692 fn first_mention_of_a_header_name_wins() {
693 let mut parts = ViewParts::new();
694 push_node(
695 &mut parts,
696 (CACHE_CONTROL, HeaderValue::from_static("no-store")),
697 );
698 let mut later = HeaderMap::new();
699 later.insert(CACHE_CONTROL, HeaderValue::from_static("max-age=60"));
700 later.insert(
701 HeaderName::from_static("x-extra"),
702 HeaderValue::from_static("1"),
703 );
704 push_node(&mut parts, later);
705
706 let rendered = View::new(parts).render_response(&Cx::default());
707 assert_eq!(rendered.headers[CACHE_CONTROL], "no-store");
708 assert_eq!(rendered.headers["x-extra"], "1");
709 }
710
711 #[test]
712 fn one_map_keeps_all_values_for_a_name() {
713 let mut first = HeaderMap::new();
714 first.append(SET_COOKIE, HeaderValue::from_static("a=1"));
715 first.append(SET_COOKIE, HeaderValue::from_static("b=2"));
716 let mut later = HeaderMap::new();
717 later.insert(SET_COOKIE, HeaderValue::from_static("c=3"));
718
719 let mut parts = ViewParts::new();
720 push_node(&mut parts, first);
721 push_node(&mut parts, later);
722
723 let rendered = View::new(parts).render_response(&Cx::default());
724 let cookies: Vec<_> = rendered.headers.get_all(SET_COOKIE).iter().collect();
725 assert_eq!(cookies, ["a=1", "b=2"]);
726 }
727
728 #[test]
729 fn placement_decides_precedence_across_nested_views() {
730 let mut inner_parts = ViewParts::new();
731 push_node(&mut inner_parts, StatusCode::NOT_FOUND);
732 push_text(&mut inner_parts, "inner");
733 let inner = View::new(inner_parts);
734
735 let mut outer_parts = ViewParts::new();
737 push_node(&mut outer_parts, StatusCode::FORBIDDEN);
738 outer_parts.push_view(inner.clone());
739 let rendered = View::new(outer_parts).render_response(&Cx::default());
740 assert_eq!(rendered.status_code, Some(StatusCode::FORBIDDEN));
741
742 let mut outer_parts = ViewParts::new();
744 outer_parts.push_view(inner);
745 push_node(&mut outer_parts, StatusCode::FORBIDDEN);
746 let rendered = View::new(outer_parts).render_response(&Cx::default());
747 assert_eq!(rendered.status_code, Some(StatusCode::NOT_FOUND));
748 }
749 }
750}