laterite-web 0.9.20

Laterite CMF public web layer: themes, static-site generation, page metadata, canonical URLs, and sitemaps
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
//! Components: compiled units a page or layout attaches, each producing data
//! a template reads under the alias it was attached as.
//!
//! A module implements [`Component`] and registers a [`ComponentReg`]. A page
//! or layout attaches one in its settings under `components`, keyed by alias,
//! naming the component and its properties; a property written as
//! `{{ :name }}` takes the URL parameter of that name at request time:
//!
//! ```json
//! {
//!   "url": "/blog/:slug",
//!   "components": {
//!     "post": { "component": "acme.post", "slug": "{{ :slug }}" },
//!     "latest": { "component": "acme.posts", "limit": 5 }
//!   }
//! }
//! ```
//!
//! The site runs every attached component before the page renders, in the
//! order attached (the layout's first, then the page's, a page alias winning
//! over a layout's), and exposes each result as `{{ post }}` and `{{ latest }}`
//! plus any variables a component injects into the page scope. A save checks
//! the attachment: the component must be registered and every property one
//! it declares, of the kind declared.

use super::router::Params;
use super::ThemeError;
use laterite_admin::editor::{Snippet, SnippetProperty, SnippetPropertyKind};
use laterite_core::Db;
use std::collections::{BTreeMap, HashSet};
use std::sync::Arc;

/// What a component property holds.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PropertyKind {
    Text,
    Number,
    Bool,
}

/// A property a component declares, for the editor and the save-time check.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct PropertyDef {
    pub name: String,
    pub label: String,
    pub kind: PropertyKind,
    pub required: bool,
    /// The value used when the attachment leaves the property out.
    pub default: Option<serde_json::Value>,
}

impl PropertyDef {
    pub fn new(name: impl Into<String>, label: impl Into<String>, kind: PropertyKind) -> Self {
        Self {
            name: name.into(),
            label: label.into(),
            kind,
            required: false,
            default: None,
        }
    }

    pub fn required(mut self) -> Self {
        self.required = true;
        self
    }

    pub fn default(mut self, value: impl Into<serde_json::Value>) -> Self {
        self.default = Some(value.into());
        self
    }
}

/// What a component runs with.
pub struct ComponentContext<'a> {
    pub db: &'a Db,
    /// The properties as attached, with `{{ :name }}` resolved and defaults filled.
    pub properties: &'a serde_json::Map<String, serde_json::Value>,
    /// The URL's parameters.
    pub params: &'a Params,
    /// The request path.
    pub path: &'a str,
    /// The theme being rendered.
    pub theme: &'a str,
    /// Whether an operator is previewing.
    pub preview: bool,
}

/// What a component produced.
#[derive(Debug, Clone, Default)]
pub struct ComponentOutput {
    /// Read as the alias the component was attached under.
    pub data: serde_json::Value,
    /// Variables placed in the page scope under their own names.
    pub inject: serde_json::Map<String, serde_json::Value>,
}

impl ComponentOutput {
    /// Data under the alias and nothing injected.
    pub fn data(value: impl Into<serde_json::Value>) -> Self {
        Self {
            data: value.into(),
            inject: Default::default(),
        }
    }

    /// Adds a variable to the page scope.
    pub fn inject(mut self, name: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
        self.inject.insert(name.into(), value.into());
        self
    }
}

/// A compiled unit a page attaches.
#[laterite_core::strata::async_trait]
pub trait Component: Send + Sync + 'static {
    /// The properties an attachment may set.
    fn properties(&self) -> Vec<PropertyDef> {
        Vec::new()
    }

    /// The component's default markup, rendered by `component("alias")` when
    /// the theme has no `partials/<alias>/default.htm` of its own. It reads
    /// the component's data as `component` and as the alias.
    fn markup(&self) -> Option<&'static str> {
        None
    }

    /// Produces the data for one request.
    async fn run(&self, cx: &ComponentContext<'_>) -> Result<ComponentOutput, ThemeError>;
}

/// A registered component: its `vendor.name` code, a label for the editor,
/// and the unit.
#[non_exhaustive]
#[derive(Clone)]
pub struct ComponentReg {
    pub code: String,
    pub label: String,
    pub component: Arc<dyn Component>,
    /// Set when authors may insert the component as a snippet.
    pub snippet: Option<SnippetInfo>,
}

/// How a component shows in the editors' Insert menu.
#[derive(Debug, Clone)]
pub struct SnippetInfo {
    pub name: String,
    pub description: Option<String>,
}

impl ComponentReg {
    pub fn new(
        code: impl Into<String>,
        label: impl Into<String>,
        component: Arc<dyn Component>,
    ) -> Self {
        Self {
            code: code.into(),
            label: label.into(),
            component,
            snippet: None,
        }
    }

    /// Offers the component to authors as a snippet: its properties become
    /// the dialog's fields, and a `<figure data-snippet="<code>">` in content
    /// runs it and renders its markup where the figure stood.
    pub fn snippet(mut self, name: impl Into<String>, description: Option<&str>) -> Self {
        self.snippet = Some(SnippetInfo {
            name: name.into(),
            description: description.map(str::to_string),
        });
        self
    }
}

/// The component as the Insert menu lists it, when it is a snippet.
pub fn snippet_of(reg: &ComponentReg) -> Option<Snippet> {
    let info = reg.snippet.as_ref()?;
    Some(Snippet {
        code: reg.code.clone(),
        name: info.name.clone(),
        description: info.description.clone(),
        properties: reg
            .component
            .properties()
            .iter()
            .map(|p| SnippetProperty {
                code: p.name.clone(),
                title: p.label.clone(),
                kind: match p.kind {
                    PropertyKind::Bool => SnippetPropertyKind::Checkbox,
                    PropertyKind::Text | PropertyKind::Number => SnippetPropertyKind::String,
                },
                default: p.default.clone(),
                options: Vec::new(),
            })
            .collect(),
    })
}

/// The codes of the components offered as snippets.
pub fn snippet_codes(regs: &[&ComponentReg]) -> HashSet<String> {
    regs.iter()
        .filter(|r| r.snippet.is_some())
        .map(|r| r.code.clone())
        .collect()
}

/// A figure's properties as the component runs with them: the declared
/// defaults, then the figure's values read to the declared kinds (the dialog
/// stores text).
pub fn snippet_properties(
    defs: &[PropertyDef],
    given: &serde_json::Map<String, serde_json::Value>,
) -> serde_json::Map<String, serde_json::Value> {
    let mut out = serde_json::Map::new();
    for def in defs {
        if let Some(default) = &def.default {
            out.insert(def.name.clone(), default.clone());
        }
    }
    for def in defs {
        let Some(value) = given.get(&def.name) else {
            continue;
        };
        let typed = match (def.kind, value) {
            (PropertyKind::Number, serde_json::Value::String(s)) => {
                let s = s.trim();
                s.parse::<i64>()
                    .map(serde_json::Value::from)
                    .ok()
                    .or_else(|| s.parse::<f64>().ok().map(serde_json::Value::from))
                    .unwrap_or(serde_json::Value::Null)
            }
            (PropertyKind::Bool, serde_json::Value::String(s)) => {
                serde_json::Value::Bool(matches!(s.trim(), "true" | "1" | "on" | "yes"))
            }
            _ => value.clone(),
        };
        out.insert(def.name.clone(), typed);
    }
    out
}

/// The environment name of a component's default markup.
pub fn template_name(code: &str) -> String {
    format!("components/{code}.htm")
}

/// The context key holding each alias's component code for `component()`.
pub const ALIASES_KEY: &str = "__components";

/// One attachment: an alias, a component code and its raw properties.
#[derive(Debug, Clone, PartialEq)]
pub struct Attachment {
    pub alias: String,
    pub code: String,
    pub properties: serde_json::Map<String, serde_json::Value>,
}

/// The attachments in a settings bag's `components`, in the order written.
pub fn attached(bag: &serde_json::Value) -> Result<Vec<Attachment>, ThemeError> {
    let Some(map) = bag.get("components") else {
        return Ok(Vec::new());
    };
    let Some(map) = map.as_object() else {
        return Err(ThemeError::Page(
            "components must be an object keyed by alias".into(),
        ));
    };
    let mut out = Vec::new();
    for (alias, spec) in map {
        if alias.is_empty() || !alias.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
            return Err(ThemeError::Page(format!(
                "{alias:?} is not a component alias: letters, digits and underscores"
            )));
        }
        let Some(spec) = spec.as_object() else {
            return Err(ThemeError::Page(format!(
                "the component {alias:?} must be an object with a component code"
            )));
        };
        let Some(code) = spec.get("component").and_then(|c| c.as_str()) else {
            return Err(ThemeError::Page(format!(
                "the component {alias:?} names no component"
            )));
        };
        let mut properties = spec.clone();
        properties.remove("component");
        out.push(Attachment {
            alias: alias.clone(),
            code: code.to_string(),
            properties,
        });
    }
    Ok(out)
}

/// Checks a bag's attachments against the registered components: each named
/// one exists, each property is declared and of its kind (a `{{ :name }}`
/// reference passes for any kind), and every required one is set.
pub fn check(bag: &serde_json::Value, regs: &[&ComponentReg]) -> Result<(), ThemeError> {
    for a in attached(bag)? {
        let Some(reg) = regs.iter().find(|r| r.code == a.code) else {
            return Err(ThemeError::Page(format!(
                "no component {:?} for {:?}",
                a.code, a.alias
            )));
        };
        let defs = reg.component.properties();
        for (name, value) in &a.properties {
            let Some(def) = defs.iter().find(|d| d.name == *name) else {
                return Err(ThemeError::Page(format!(
                    "{:?} has no property {name:?}",
                    a.code
                )));
            };
            if external(value).is_some() {
                continue;
            }
            let ok = match def.kind {
                PropertyKind::Text => value.is_string(),
                PropertyKind::Number => value.is_number(),
                PropertyKind::Bool => value.is_boolean(),
            };
            if !ok {
                return Err(ThemeError::Page(format!(
                    "the property {name:?} of {:?} takes a {}",
                    a.code,
                    match def.kind {
                        PropertyKind::Text => "text",
                        PropertyKind::Number => "number",
                        PropertyKind::Bool => "true or false",
                    }
                )));
            }
        }
        for def in defs.iter().filter(|d| d.required && d.default.is_none()) {
            if !a.properties.contains_key(&def.name) {
                return Err(ThemeError::Page(format!(
                    "{:?} needs the property {:?}",
                    a.code, def.name
                )));
            }
        }
    }
    Ok(())
}

/// The URL parameter a property value refers to, for `{{ :name }}`.
fn external(value: &serde_json::Value) -> Option<&str> {
    let text = value.as_str()?.trim();
    let inner = text.strip_prefix("{{")?.strip_suffix("}}")?.trim();
    inner.strip_prefix(':').map(str::trim)
}

/// The properties a component runs with: the attachment's, with `{{ :name }}`
/// resolved from `params` and the declared defaults filled.
pub fn resolve(
    a: &Attachment,
    defs: &[PropertyDef],
    params: &Params,
) -> serde_json::Map<String, serde_json::Value> {
    let mut out = serde_json::Map::new();
    for def in defs {
        if let Some(default) = &def.default {
            out.insert(def.name.clone(), default.clone());
        }
    }
    for (name, value) in &a.properties {
        let value = match external(value) {
            Some(param) => params
                .get(param)
                .cloned()
                .map(serde_json::Value::String)
                .unwrap_or(serde_json::Value::Null),
            None => value.clone(),
        };
        out.insert(name.clone(), value);
    }
    out
}

/// What running the attachments of a page gave: a value per alias, then the
/// injected variables, as pairs for the render context.
pub struct Ran {
    pub pairs: Vec<(String, serde_json::Value)>,
}

/// Adds every registered component's default markup to `env`, so
/// `component()` can fall back to it.
pub fn install_markup(regs: &[&ComponentReg], env: &mut minijinja::Environment<'static>) {
    for reg in regs {
        if let Some(markup) = reg.component.markup() {
            // A default that does not compile is a module bug, logged and skipped.
            if let Err(e) = env.add_template_owned(template_name(&reg.code), markup.to_string()) {
                tracing::error!(component = %reg.code, error = %e, "a component's default markup does not compile");
            }
        }
    }
}

/// Runs every attachment in `bags` (a layout's, then a page's; a later alias
/// replaces an earlier one), returning the context pairs.
pub async fn run_all(
    bags: &[&serde_json::Value],
    regs: &[&ComponentReg],
    db: &Db,
    params: &Params,
    path: &str,
    theme: &str,
    preview: bool,
) -> Result<Ran, ThemeError> {
    let mut by_alias: BTreeMap<String, Attachment> = BTreeMap::new();
    for bag in bags {
        for a in attached(bag)? {
            by_alias.insert(a.alias.clone(), a);
        }
    }
    let mut pairs: Vec<(String, serde_json::Value)> = Vec::new();
    let mut injected: Vec<(String, serde_json::Value)> = Vec::new();
    for a in by_alias.values() {
        let Some(reg) = regs.iter().find(|r| r.code == a.code) else {
            return Err(ThemeError::Page(format!(
                "no component {:?} for {:?}",
                a.code, a.alias
            )));
        };
        let properties = resolve(a, &reg.component.properties(), params);
        let cx = ComponentContext {
            db,
            properties: &properties,
            params,
            path,
            theme,
            preview,
        };
        let output = reg.component.run(&cx).await?;
        pairs.push((a.alias.clone(), output.data));
        injected.extend(output.inject);
    }
    // An alias wins over an injected variable of the same name.
    for (name, value) in injected {
        if !pairs.iter().any(|(n, _)| *n == name) {
            pairs.push((name, value));
        }
    }
    let aliases: serde_json::Map<String, serde_json::Value> = by_alias
        .values()
        .map(|a| (a.alias.clone(), serde_json::Value::String(a.code.clone())))
        .collect();
    pairs.push((ALIASES_KEY.to_string(), serde_json::Value::Object(aliases)));
    Ok(Ran { pairs })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn attachments_parse_and_external_properties_resolve() {
        let bag = serde_json::json!({
            "url": "/blog/:slug",
            "components": {
                "post": {"component": "acme.post", "slug": "{{ :slug }}", "full": true},
                "latest": {"component": "acme.posts", "limit": 5}
            }
        });
        let list = attached(&bag).unwrap();
        assert_eq!(list.len(), 2);
        assert_eq!(list[0].alias, "post");
        assert_eq!(list[1].code, "acme.posts");
        let defs = vec![
            PropertyDef::new("slug", "Slug", PropertyKind::Text).required(),
            PropertyDef::new("full", "Full", PropertyKind::Bool).default(false),
            PropertyDef::new("limit", "Limit", PropertyKind::Number).default(10),
        ];
        let params: Params = [("slug".to_string(), "hello".to_string())].into();
        let resolved = resolve(&list[0], &defs, &params);
        assert_eq!(resolved["slug"], "hello");
        assert_eq!(resolved["full"], true);
        assert_eq!(resolved["limit"], 10);
        assert!(attached(&serde_json::json!({"components": {"bad alias": {}}})).is_err());
        assert!(attached(&serde_json::json!({"components": {"a": {"x": 1}}})).is_err());
        assert!(attached(&serde_json::json!({"components": []})).is_err());
        assert!(attached(&serde_json::json!({"url": "/"}))
            .unwrap()
            .is_empty());
    }
}