Skip to main content

laterite_web/theme/
component.rs

1//! Components: compiled units a page or layout attaches, each producing data
2//! a template reads under the alias it was attached as.
3//!
4//! A module implements [`Component`] and registers a [`ComponentReg`]. A page
5//! or layout attaches one in its settings under `components`, keyed by alias,
6//! naming the component and its properties; a property written as
7//! `{{ :name }}` takes the URL parameter of that name at request time:
8//!
9//! ```json
10//! {
11//!   "url": "/blog/:slug",
12//!   "components": {
13//!     "post": { "component": "acme.post", "slug": "{{ :slug }}" },
14//!     "latest": { "component": "acme.posts", "limit": 5 }
15//!   }
16//! }
17//! ```
18//!
19//! The site runs every attached component before the page renders, in the
20//! order attached (the layout's first, then the page's, a page alias winning
21//! over a layout's), and exposes each result as `{{ post }}` and `{{ latest }}`
22//! plus any variables a component injects into the page scope. A save checks
23//! the attachment: the component must be registered and every property one
24//! it declares, of the kind declared.
25
26use super::router::Params;
27use super::ThemeError;
28use laterite_core::Db;
29use std::collections::BTreeMap;
30use std::sync::Arc;
31
32/// What a component property holds.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum PropertyKind {
35    Text,
36    Number,
37    Bool,
38}
39
40/// A property a component declares, for the editor and the save-time check.
41#[derive(Debug, Clone)]
42#[non_exhaustive]
43pub struct PropertyDef {
44    pub name: String,
45    pub label: String,
46    pub kind: PropertyKind,
47    pub required: bool,
48    /// The value used when the attachment leaves the property out.
49    pub default: Option<serde_json::Value>,
50}
51
52impl PropertyDef {
53    pub fn new(name: impl Into<String>, label: impl Into<String>, kind: PropertyKind) -> Self {
54        Self {
55            name: name.into(),
56            label: label.into(),
57            kind,
58            required: false,
59            default: None,
60        }
61    }
62
63    pub fn required(mut self) -> Self {
64        self.required = true;
65        self
66    }
67
68    pub fn default(mut self, value: impl Into<serde_json::Value>) -> Self {
69        self.default = Some(value.into());
70        self
71    }
72}
73
74/// What a component runs with.
75pub struct ComponentContext<'a> {
76    pub db: &'a Db,
77    /// The properties as attached, with `{{ :name }}` resolved and defaults filled.
78    pub properties: &'a serde_json::Map<String, serde_json::Value>,
79    /// The URL's parameters.
80    pub params: &'a Params,
81    /// The request path.
82    pub path: &'a str,
83    /// The theme being rendered.
84    pub theme: &'a str,
85    /// Whether an operator is previewing.
86    pub preview: bool,
87}
88
89/// What a component produced.
90#[derive(Debug, Clone, Default)]
91pub struct ComponentOutput {
92    /// Read as the alias the component was attached under.
93    pub data: serde_json::Value,
94    /// Variables placed in the page scope under their own names.
95    pub inject: serde_json::Map<String, serde_json::Value>,
96}
97
98impl ComponentOutput {
99    /// Data under the alias and nothing injected.
100    pub fn data(value: impl Into<serde_json::Value>) -> Self {
101        Self {
102            data: value.into(),
103            inject: Default::default(),
104        }
105    }
106
107    /// Adds a variable to the page scope.
108    pub fn inject(mut self, name: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
109        self.inject.insert(name.into(), value.into());
110        self
111    }
112}
113
114/// A compiled unit a page attaches.
115#[laterite_core::strata::async_trait]
116pub trait Component: Send + Sync + 'static {
117    /// The properties an attachment may set.
118    fn properties(&self) -> Vec<PropertyDef> {
119        Vec::new()
120    }
121
122    /// The component's default markup, rendered by `component("alias")` when
123    /// the theme has no `partials/<alias>/default.htm` of its own. It reads
124    /// the component's data as `component` and as the alias.
125    fn markup(&self) -> Option<&'static str> {
126        None
127    }
128
129    /// Produces the data for one request.
130    async fn run(&self, cx: &ComponentContext<'_>) -> Result<ComponentOutput, ThemeError>;
131}
132
133/// A registered component: its `vendor.name` code, a label for the editor,
134/// and the unit.
135#[non_exhaustive]
136pub struct ComponentReg {
137    pub code: String,
138    pub label: String,
139    pub component: Arc<dyn Component>,
140}
141
142impl ComponentReg {
143    pub fn new(
144        code: impl Into<String>,
145        label: impl Into<String>,
146        component: Arc<dyn Component>,
147    ) -> Self {
148        Self {
149            code: code.into(),
150            label: label.into(),
151            component,
152        }
153    }
154}
155
156/// The environment name of a component's default markup.
157pub fn template_name(code: &str) -> String {
158    format!("components/{code}.htm")
159}
160
161/// The context key holding each alias's component code for `component()`.
162pub const ALIASES_KEY: &str = "__components";
163
164/// One attachment: an alias, a component code and its raw properties.
165#[derive(Debug, Clone, PartialEq)]
166pub struct Attachment {
167    pub alias: String,
168    pub code: String,
169    pub properties: serde_json::Map<String, serde_json::Value>,
170}
171
172/// The attachments in a settings bag's `components`, in the order written.
173pub fn attached(bag: &serde_json::Value) -> Result<Vec<Attachment>, ThemeError> {
174    let Some(map) = bag.get("components") else {
175        return Ok(Vec::new());
176    };
177    let Some(map) = map.as_object() else {
178        return Err(ThemeError::Page(
179            "components must be an object keyed by alias".into(),
180        ));
181    };
182    let mut out = Vec::new();
183    for (alias, spec) in map {
184        if alias.is_empty() || !alias.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
185            return Err(ThemeError::Page(format!(
186                "{alias:?} is not a component alias: letters, digits and underscores"
187            )));
188        }
189        let Some(spec) = spec.as_object() else {
190            return Err(ThemeError::Page(format!(
191                "the component {alias:?} must be an object with a component code"
192            )));
193        };
194        let Some(code) = spec.get("component").and_then(|c| c.as_str()) else {
195            return Err(ThemeError::Page(format!(
196                "the component {alias:?} names no component"
197            )));
198        };
199        let mut properties = spec.clone();
200        properties.remove("component");
201        out.push(Attachment {
202            alias: alias.clone(),
203            code: code.to_string(),
204            properties,
205        });
206    }
207    Ok(out)
208}
209
210/// Checks a bag's attachments against the registered components: each named
211/// one exists, each property is declared and of its kind (a `{{ :name }}`
212/// reference passes for any kind), and every required one is set.
213pub fn check(bag: &serde_json::Value, regs: &[&ComponentReg]) -> Result<(), ThemeError> {
214    for a in attached(bag)? {
215        let Some(reg) = regs.iter().find(|r| r.code == a.code) else {
216            return Err(ThemeError::Page(format!(
217                "no component {:?} for {:?}",
218                a.code, a.alias
219            )));
220        };
221        let defs = reg.component.properties();
222        for (name, value) in &a.properties {
223            let Some(def) = defs.iter().find(|d| d.name == *name) else {
224                return Err(ThemeError::Page(format!(
225                    "{:?} has no property {name:?}",
226                    a.code
227                )));
228            };
229            if external(value).is_some() {
230                continue;
231            }
232            let ok = match def.kind {
233                PropertyKind::Text => value.is_string(),
234                PropertyKind::Number => value.is_number(),
235                PropertyKind::Bool => value.is_boolean(),
236            };
237            if !ok {
238                return Err(ThemeError::Page(format!(
239                    "the property {name:?} of {:?} takes a {}",
240                    a.code,
241                    match def.kind {
242                        PropertyKind::Text => "text",
243                        PropertyKind::Number => "number",
244                        PropertyKind::Bool => "true or false",
245                    }
246                )));
247            }
248        }
249        for def in defs.iter().filter(|d| d.required && d.default.is_none()) {
250            if !a.properties.contains_key(&def.name) {
251                return Err(ThemeError::Page(format!(
252                    "{:?} needs the property {:?}",
253                    a.code, def.name
254                )));
255            }
256        }
257    }
258    Ok(())
259}
260
261/// The URL parameter a property value refers to, for `{{ :name }}`.
262fn external(value: &serde_json::Value) -> Option<&str> {
263    let text = value.as_str()?.trim();
264    let inner = text.strip_prefix("{{")?.strip_suffix("}}")?.trim();
265    inner.strip_prefix(':').map(str::trim)
266}
267
268/// The properties a component runs with: the attachment's, with `{{ :name }}`
269/// resolved from `params` and the declared defaults filled.
270pub fn resolve(
271    a: &Attachment,
272    defs: &[PropertyDef],
273    params: &Params,
274) -> serde_json::Map<String, serde_json::Value> {
275    let mut out = serde_json::Map::new();
276    for def in defs {
277        if let Some(default) = &def.default {
278            out.insert(def.name.clone(), default.clone());
279        }
280    }
281    for (name, value) in &a.properties {
282        let value = match external(value) {
283            Some(param) => params
284                .get(param)
285                .cloned()
286                .map(serde_json::Value::String)
287                .unwrap_or(serde_json::Value::Null),
288            None => value.clone(),
289        };
290        out.insert(name.clone(), value);
291    }
292    out
293}
294
295/// What running the attachments of a page gave: a value per alias, then the
296/// injected variables, as pairs for the render context.
297pub struct Ran {
298    pub pairs: Vec<(String, serde_json::Value)>,
299}
300
301/// Adds every registered component's default markup to `env`, so
302/// `component()` can fall back to it.
303pub fn install_markup(regs: &[&ComponentReg], env: &mut minijinja::Environment<'static>) {
304    for reg in regs {
305        if let Some(markup) = reg.component.markup() {
306            // A default that does not compile is a module bug, logged and skipped.
307            if let Err(e) = env.add_template_owned(template_name(&reg.code), markup.to_string()) {
308                tracing::error!(component = %reg.code, error = %e, "a component's default markup does not compile");
309            }
310        }
311    }
312}
313
314/// Runs every attachment in `bags` (a layout's, then a page's; a later alias
315/// replaces an earlier one), returning the context pairs.
316pub async fn run_all(
317    bags: &[&serde_json::Value],
318    regs: &[&ComponentReg],
319    db: &Db,
320    params: &Params,
321    path: &str,
322    theme: &str,
323    preview: bool,
324) -> Result<Ran, ThemeError> {
325    let mut by_alias: BTreeMap<String, Attachment> = BTreeMap::new();
326    for bag in bags {
327        for a in attached(bag)? {
328            by_alias.insert(a.alias.clone(), a);
329        }
330    }
331    let mut pairs: Vec<(String, serde_json::Value)> = Vec::new();
332    let mut injected: Vec<(String, serde_json::Value)> = Vec::new();
333    for a in by_alias.values() {
334        let Some(reg) = regs.iter().find(|r| r.code == a.code) else {
335            return Err(ThemeError::Page(format!(
336                "no component {:?} for {:?}",
337                a.code, a.alias
338            )));
339        };
340        let properties = resolve(a, &reg.component.properties(), params);
341        let cx = ComponentContext {
342            db,
343            properties: &properties,
344            params,
345            path,
346            theme,
347            preview,
348        };
349        let output = reg.component.run(&cx).await?;
350        pairs.push((a.alias.clone(), output.data));
351        injected.extend(output.inject);
352    }
353    // An alias wins over an injected variable of the same name.
354    for (name, value) in injected {
355        if !pairs.iter().any(|(n, _)| *n == name) {
356            pairs.push((name, value));
357        }
358    }
359    let aliases: serde_json::Map<String, serde_json::Value> = by_alias
360        .values()
361        .map(|a| (a.alias.clone(), serde_json::Value::String(a.code.clone())))
362        .collect();
363    pairs.push((ALIASES_KEY.to_string(), serde_json::Value::Object(aliases)));
364    Ok(Ran { pairs })
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn attachments_parse_and_external_properties_resolve() {
373        let bag = serde_json::json!({
374            "url": "/blog/:slug",
375            "components": {
376                "post": {"component": "acme.post", "slug": "{{ :slug }}", "full": true},
377                "latest": {"component": "acme.posts", "limit": 5}
378            }
379        });
380        let list = attached(&bag).unwrap();
381        assert_eq!(list.len(), 2);
382        assert_eq!(list[0].alias, "post");
383        assert_eq!(list[1].code, "acme.posts");
384        let defs = vec![
385            PropertyDef::new("slug", "Slug", PropertyKind::Text).required(),
386            PropertyDef::new("full", "Full", PropertyKind::Bool).default(false),
387            PropertyDef::new("limit", "Limit", PropertyKind::Number).default(10),
388        ];
389        let params: Params = [("slug".to_string(), "hello".to_string())].into();
390        let resolved = resolve(&list[0], &defs, &params);
391        assert_eq!(resolved["slug"], "hello");
392        assert_eq!(resolved["full"], true);
393        assert_eq!(resolved["limit"], 10);
394        assert!(attached(&serde_json::json!({"components": {"bad alias": {}}})).is_err());
395        assert!(attached(&serde_json::json!({"components": {"a": {"x": 1}}})).is_err());
396        assert!(attached(&serde_json::json!({"components": []})).is_err());
397        assert!(attached(&serde_json::json!({"url": "/"}))
398            .unwrap()
399            .is_empty());
400    }
401}