Skip to main content

sofie_engine/
template.rs

1use std::sync::atomic::{AtomicUsize, Ordering};
2
3use sofie_core::{Show, Shown};
4use sofie_values::FormField;
5
6use crate::catalog::{self, Submitted};
7use crate::error::{RenderError, TemplateError};
8use crate::language::Language;
9use crate::page::CLEARED_NOTICE;
10use crate::render;
11use crate::syntax::{LocatedError, Op};
12
13/// A rare huge render, a long listing say, must not make every later render reserve as much.
14const MAXIMUM_CAPACITY_HINT: usize = 256 * 1024;
15
16#[derive(Debug)]
17pub struct Template {
18    pub(crate) name: String,
19    pub(crate) ops: Vec<Op>,
20    /// The last render's length, so the next one allocates once.
21    document_capacity: AtomicUsize,
22    fragment_capacity: AtomicUsize,
23    pub(crate) precomputed: Precomputed,
24}
25
26/// What a template's ops imply beside rendering them, worked out once.
27#[derive(Debug)]
28pub(crate) struct Precomputed {
29    #[cfg(feature = "clients")]
30    pub(crate) client_parts: std::sync::OnceLock<crate::client_view::ClientParts>,
31    #[cfg(feature = "gates")]
32    pub(crate) gated_paths: crate::rendering::GatedPaths,
33}
34
35impl Precomputed {
36    #[cfg_attr(
37        not(feature = "gates"),
38        expect(unused_variables, reason = "only gates are read from the ops up front")
39    )]
40    fn of(ops: &[Op]) -> Self {
41        Self {
42            #[cfg(feature = "clients")]
43            client_parts: std::sync::OnceLock::new(),
44            #[cfg(feature = "gates")]
45            gated_paths: crate::rendering::GatedPaths::of(ops),
46        }
47    }
48
49    /// A form's description changes what a client view says of it.
50    #[cfg_attr(
51        not(feature = "clients"),
52        expect(clippy::unused_self, reason = "only a client view describes forms")
53    )]
54    fn forget_forms(&mut self) {
55        #[cfg(feature = "clients")]
56        self.client_parts.take();
57    }
58}
59
60impl Template {
61    pub(crate) fn new(name: &str, ops: Vec<Op>) -> Self {
62        Self {
63            precomputed: Precomputed::of(&ops),
64            name: name.to_owned(),
65            ops,
66            document_capacity: AtomicUsize::new(0),
67            fragment_capacity: AtomicUsize::new(0),
68        }
69    }
70
71    #[cfg(feature = "parse")]
72    pub fn parse(name: &str, source: &str) -> Result<Self, TemplateError> {
73        Self::parse_with(name, source, &crate::Components::default())
74    }
75
76    #[cfg(feature = "parse")]
77    pub fn parse_with(
78        name: &str,
79        source: &str,
80        components: &crate::Components,
81    ) -> Result<Self, TemplateError> {
82        crate::compiled::parse_template(name, source, components)
83    }
84
85    /// Fails when the template has no such form or a `Field` is not one of the input's fields.
86    pub fn describe_form(
87        &mut self,
88        form_id: &str,
89        fields: &'static [FormField],
90    ) -> Result<(), TemplateError> {
91        self.precomputed.forget_forms();
92        match catalog::describe_forms(&mut self.ops, form_id, fields) {
93            Ok(true) => Ok(()),
94            Ok(false) => Err(TemplateError::unlocated(
95                &self.name,
96                format!("there is no `Form` with id `{form_id}` to describe"),
97            )),
98            Err(error) => Err(self.defect(error)),
99        }
100    }
101
102    #[must_use]
103    pub fn name(&self) -> &str {
104        &self.name
105    }
106
107    pub fn render(&self, data: &(impl Show + ?Sized)) -> Result<String, TemplateError> {
108        render_whole(self, data.show(), None, Language::plain())
109    }
110
111    pub fn render_submitted(
112        &self,
113        data: &(impl Show + ?Sized),
114        submitted: &Submitted<'_>,
115    ) -> Result<String, TemplateError> {
116        render_whole(self, data.show(), Some(submitted), Language::plain())
117    }
118
119    pub fn render_fragment(
120        &self,
121        data: &(impl Show + ?Sized),
122        id: &str,
123    ) -> Result<String, RenderError> {
124        render_part(self, data.show(), id, Language::plain())
125    }
126
127    /// Marked `hx-swap-oob`, so htmx places it whatever the request targeted.
128    pub fn render_out_of_band(
129        &self,
130        data: &(impl Show + ?Sized),
131        id: &str,
132    ) -> Result<String, RenderError> {
133        self.render_fragment(data, id).map(mark_out_of_band)
134    }
135
136    fn defect(&self, error: LocatedError) -> TemplateError {
137        TemplateError::located(&self.name, error)
138    }
139}
140
141/// The fields `Form` `form_id` shows an error under, or `None` when the template has no such
142/// form: an error for any other name, or for a post without a form, belongs in the notice.
143#[must_use]
144pub fn form_fields<'t>(template: &'t Template, form_id: &str) -> Option<Vec<&'t str>> {
145    let mut names = None;
146    catalog::form_fields(&template.ops, form_id, &mut names);
147    names
148}
149
150pub(crate) fn render_whole<'a>(
151    template: &'a Template,
152    data: Shown<'a>,
153    submitted: Option<&'a Submitted<'a>>,
154    language: Language<'a>,
155) -> Result<String, TemplateError> {
156    let capacity = template.document_capacity.load(Ordering::Relaxed);
157    let html = render::render_document(&template.ops, data, capacity, submitted, language)
158        .map_err(|error| template.defect(error))?;
159    template
160        .document_capacity
161        .store(html.len().min(MAXIMUM_CAPACITY_HINT), Ordering::Relaxed);
162    Ok(html)
163}
164
165pub(crate) fn render_part<'a>(
166    template: &'a Template,
167    data: Shown<'a>,
168    id: &'a str,
169    language: Language<'a>,
170) -> Result<String, RenderError> {
171    // Room for the cleared notice an htmx reply appends, so appending does not copy.
172    let capacity = template
173        .fragment_capacity
174        .load(Ordering::Relaxed)
175        .saturating_add(CLEARED_NOTICE.len());
176    let html = render::render_fragment(&template.ops, data, id, capacity, language)
177        .map_err(|error| RenderError::Template(template.defect(error)))?
178        .ok_or_else(|| RenderError::FragmentNotFound { id: id.to_owned() })?;
179    template
180        .fragment_capacity
181        .store(html.len().min(MAXIMUM_CAPACITY_HINT), Ordering::Relaxed);
182    Ok(html)
183}
184
185pub(crate) fn mark_out_of_band(mut html: String) -> String {
186    if let Some(name_length) = html
187        .strip_prefix('<')
188        .and_then(|rest| rest.find([' ', '>']))
189    {
190        html.insert_str(1 + name_length, " hx-swap-oob=\"true\"");
191    }
192    html
193}
194
195#[cfg(all(test, feature = "parse"))]
196mod tests {
197    use std::sync::atomic::Ordering;
198
199    use serde_json::json;
200
201    use super::{MAXIMUM_CAPACITY_HINT, Template};
202
203    #[test]
204    fn one_huge_render_does_not_make_every_later_one_reserve_as_much() {
205        let template = Template::parse("rows", "<p>{data.text}</p>").unwrap();
206
207        template
208            .render(&json!({"text": "x".repeat(4 * MAXIMUM_CAPACITY_HINT)}))
209            .unwrap();
210
211        assert_eq!(
212            template.document_capacity.load(Ordering::Relaxed),
213            MAXIMUM_CAPACITY_HINT
214        );
215    }
216}