laterite-web 0.9.16

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
//! Themes: the public site's templates, stored as rows and edited from the
//! admin.
//!
//! A theme is a code, a YAML config ([`ThemeConfig`]) and a set of templates
//! ([`Template`]), each a kind ([`Kind`]), a path, a settings bag and markup. A
//! theme may name a parent; a path its own rows lack is looked up through the
//! parent chain ([`store::resolve_template`]). One theme is active for the
//! site ([`ThemeSettings`]); an operator may hold a different one to edit and
//! preview ([`EditTheme`]). Every save of a template keeps what it replaced as
//! a [`Revision`], so an edit can be undone.
//!
//! Register [`ThemeModule`] with the bootstrap and its tables migrate with the
//! application's.
//!
//! ```no_run
//! use laterite_web::theme::{self, Kind, ThemeModule};
//! # async fn f(db: laterite_core::Db) -> Result<(), theme::ThemeError> {
//! let site = theme::store::create_theme(&db, "acme", "name: Acme\nauthor: Acme\n").await?;
//! theme::store::save_template(&db, site.id, Kind::Layout, "default.htm", None, "<html>{% block body %}{% endblock %}</html>", None).await?;
//! theme::set_active_theme(&db, "acme").await?;
//! # Ok(()) }
//! ```

pub mod admin;
pub mod archive;
pub mod build;
pub mod component;
pub mod engine;
pub mod maintenance;
pub mod markup;
mod migrations;
pub mod page;
pub mod router;
mod schema;
pub mod site;
pub mod store;

pub use engine::{check, variant, Engine, EngineCache, RenderError};

/// A media record as a template sees it: what a component hands over so
/// `media(file)`, `media(file, preset="card")` and `file.url|variant("card")`
/// work, beside `title`, `alt`, `width`, `height`, `kind` and `name`.
pub fn media_value(record: &laterite_media::MediaRecord) -> serde_json::Value {
    serde_json::json!({
        "id": record.id,
        "url": laterite_admin::medialib::file_url(record),
        "name": record.display_name(),
        "title": record.title,
        "alt": record.alt,
        "width": record.width,
        "height": record.height,
        "kind": record.kind().as_str(),
        "content_type": record.content_type,
    })
}

use laterite_admin::form::FormConfig;
use laterite_admin::settings::{PreferencesModel, SettingsModel};
use laterite_core::{Db, MigrationSet, Module, ModuleId};
use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;

/// The theme module: its tables migrate with the application's, and it
/// registers the Themes screen under `web.manage_themes`.
pub struct ThemeModule;

impl Module for ThemeModule {
    fn id(&self) -> ModuleId {
        ModuleId::new(migrations::MODULE_ID)
    }
    fn migrations(&self) -> MigrationSet {
        migrations::migrations()
    }
    /// The Themes tab sits at the panel's top level: `/admin/themes`.
    fn admin_base(&self) -> Option<&'static str> {
        Some("/")
    }
    /// The Themes screen and its permission.
    fn register(&self, registry: &mut laterite_core::Registry) {
        use laterite_admin::AdminRegistry;
        registry.add_permission(admin::permission());
        registry.add_permission(maintenance::permission());
        registry.add_settings(maintenance::settings_item());
        registry.add_screen(admin::screen());
        registry.add_public_route(site::route());
    }
}

/// The keys of the `theme` value a template reads that belong to the theme
/// itself; a settings field cannot take one.
pub const RESERVED_KEYS: [&str; 6] = [
    "code",
    "name",
    "author",
    "homepage",
    "description",
    "preview",
];

/// A theme's config: the YAML an operator edits, parsed on every save.
///
/// `form` declares the theme's own settings fields; `parent` names the theme
/// whose templates fill in for the ones this theme lacks; `require` lists the
/// modules the theme's templates need.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ThemeConfig {
    pub name: String,
    pub author: String,
    pub homepage: String,
    pub description: String,
    /// A URL for the theme's preview image, shown on the theme list; unset,
    /// the list shows the theme's initials.
    pub preview: Option<String>,
    pub parent: Option<String>,
    pub require: Vec<String>,
    pub form: Option<FormConfig>,
}

impl ThemeConfig {
    /// Parses a theme's YAML config. A `form` field named like one of the
    /// `theme` value's own keys (`code`, `name`, `author`, `homepage`,
    /// `description`) is refused, since the settings render beside them.
    pub fn parse(yaml: &str) -> Result<Self, ThemeError> {
        let config: Self =
            serde_saphyr::from_str(yaml).map_err(|e| ThemeError::Config(e.to_string()))?;
        if let Some(form) = &config.form {
            if let Some(field) = form
                .fields
                .iter()
                .find(|f| RESERVED_KEYS.contains(&f.name.as_str()))
            {
                return Err(ThemeError::Config(format!(
                    "the form field {:?} is reserved for the theme itself",
                    field.name
                )));
            }
        }
        Ok(config)
    }

    /// Renders the config as YAML, the form a new theme's row starts from.
    pub fn to_yaml(&self) -> Result<String, ThemeError> {
        serde_saphyr::to_string(self).map_err(|e| ThemeError::Config(e.to_string()))
    }
}

/// What a template is to the site.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
    /// The frame a page renders inside.
    Layout,
    /// A page the router serves at its URL.
    Page,
    /// A fragment a layout or page includes.
    Partial,
    /// A block of content (HTML, Markdown or text) a template places.
    Content,
    /// A text asset the site serves as a file (a stylesheet, a script).
    Asset,
}

impl Kind {
    /// Every kind, in the order the editor lists them.
    pub const ALL: [Kind; 5] = [
        Kind::Layout,
        Kind::Page,
        Kind::Partial,
        Kind::Content,
        Kind::Asset,
    ];

    /// The kind's stored name.
    pub fn as_str(self) -> &'static str {
        match self {
            Kind::Layout => "layout",
            Kind::Page => "page",
            Kind::Partial => "partial",
            Kind::Content => "content",
            Kind::Asset => "asset",
        }
    }
}

impl fmt::Display for Kind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl FromStr for Kind {
    type Err = ThemeError;
    fn from_str(s: &str) -> Result<Self, ThemeError> {
        Kind::ALL
            .into_iter()
            .find(|k| k.as_str() == s)
            .ok_or_else(|| ThemeError::Kind(s.to_string()))
    }
}

/// A theme row, with its config parsed.
#[derive(Debug, Clone)]
pub struct Theme {
    pub id: i64,
    pub code: String,
    /// The parent's code, mirrored from the config so a lookup never parses.
    pub parent: Option<String>,
    /// A locked theme refuses template saves and deletions.
    pub locked: bool,
    pub config: ThemeConfig,
    /// The YAML as the operator wrote it.
    pub config_source: String,
    /// The theme's own settings values: a JSON object keyed by the fields of
    /// the `form` in force (its own, else the nearest ancestor's). Empty
    /// until saved.
    pub settings: serde_json::Value,
    pub created_at: String,
    pub updated_at: String,
}

/// A template row.
#[derive(Debug, Clone)]
pub struct Template {
    pub id: i64,
    pub theme_id: i64,
    pub kind: Kind,
    pub path: String,
    /// The settings bag (a page's URL and layout, a layout's description).
    pub settings: serde_json::Value,
    pub markup: String,
    pub created_at: String,
    pub updated_at: String,
}

/// What a template held before a save replaced it.
#[derive(Debug, Clone)]
pub struct Revision {
    pub id: i64,
    pub template_id: i64,
    pub settings: serde_json::Value,
    pub markup: String,
    pub saved_at: String,
    /// The operator whose save made this the previous content, when known.
    pub saved_by: Option<i64>,
}

/// What the theme store can refuse or fail on.
#[derive(Debug, thiserror::Error)]
pub enum ThemeError {
    #[error("theme storage error")]
    Db(#[from] sqlx::Error),
    #[error("the theme config does not parse: {0}")]
    Config(String),
    #[error("{0:?} is not a theme code: lowercase letters, digits and hyphens, starting with a letter or digit")]
    Code(String),
    #[error("{0:?} is not a template path: segments of lowercase letters, digits, dots, hyphens and underscores")]
    Path(String),
    #[error("{0:?} is not a template kind")]
    Kind(String),
    #[error("the theme {0:?} is locked")]
    Locked(String),
    #[error("no theme {0:?}")]
    NotFound(String),
    #[error("no template {0:?}")]
    TemplateNotFound(String),
    #[error("the theme {0:?} already exists")]
    Exists(String),
    #[error("{0}")]
    Parent(String),
    #[error("the theme {0:?} is active")]
    Active(String),
    #[error("the template does not compile")]
    Template(#[source] engine::RenderError),
    #[error("{0}")]
    Page(String),
    #[error("archive error")]
    Zip(#[from] zip::result::ZipError),
    #[error("archive error")]
    Io(#[from] std::io::Error),
    #[error("settings error")]
    Settings(#[from] laterite_admin::settings::SettingsError),
    #[error("preferences error")]
    Preferences(#[from] laterite_admin::settings::PreferencesError),
}

/// The site's theme settings: which theme is active.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ThemeSettings {
    pub active: Option<String>,
}

impl SettingsModel for ThemeSettings {
    const CODE: &'static str = "laterite.theme";
}

/// An operator's own choice of theme to edit and preview.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct EditTheme {
    pub code: Option<String>,
}

impl PreferencesModel for EditTheme {
    const CODE: &'static str = "laterite.theme.edit";
}

/// The active theme, or none when no theme is set or the set one is gone.
pub async fn active_theme(db: &Db) -> Result<Option<Theme>, ThemeError> {
    let settings: ThemeSettings = laterite_admin::settings::load(db).await?;
    match settings.active {
        Some(code) => store::find_theme(db, &code).await,
        None => Ok(None),
    }
}

/// Makes `code` the site's theme. The theme must exist.
pub async fn set_active_theme(db: &Db, code: &str) -> Result<(), ThemeError> {
    store::find_theme(db, code)
        .await?
        .ok_or_else(|| ThemeError::NotFound(code.to_string()))?;
    let settings = ThemeSettings {
        active: Some(code.to_string()),
    };
    laterite_admin::settings::save(db, &settings).await?;
    Ok(())
}

/// The theme an operator edits: their own choice when it still exists, else
/// the active theme.
pub async fn edit_theme(db: &Db, user_id: i64) -> Result<Option<Theme>, ThemeError> {
    let own: EditTheme = laterite_admin::settings::load_for(db, user_id).await?;
    if let Some(code) = own.code {
        if let Some(theme) = store::find_theme(db, &code).await? {
            return Ok(Some(theme));
        }
    }
    active_theme(db).await
}

/// Sets, or with `None` clears, the theme an operator edits.
pub async fn set_edit_theme(db: &Db, user_id: i64, code: Option<&str>) -> Result<(), ThemeError> {
    if let Some(code) = code {
        store::find_theme(db, code)
            .await?
            .ok_or_else(|| ThemeError::NotFound(code.to_string()))?;
    }
    let own = EditTheme {
        code: code.map(str::to_string),
    };
    laterite_admin::settings::save_for(db, user_id, &own).await?;
    Ok(())
}

/// A theme code: lowercase letters, digits and hyphens, starting with a letter
/// or digit, at most 64 characters.
pub fn valid_code(code: &str) -> bool {
    let mut chars = code.chars();
    match chars.next() {
        Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {}
        _ => return false,
    }
    code.len() <= 64 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}

/// A template path: `/`-separated segments of lowercase letters, digits, dots,
/// hyphens and underscores, no empty, `.` or `..` segment, at most 255 characters.
pub fn valid_path(path: &str) -> bool {
    !path.is_empty()
        && path.len() <= 255
        && path.split('/').all(|seg| {
            !seg.is_empty()
                && seg != "."
                && seg != ".."
                && seg.chars().all(|c| {
                    c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '-' | '_')
                })
        })
}

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

    #[test]
    fn codes_and_paths_are_checked() {
        assert!(valid_code("acme"));
        assert!(valid_code("acme-2024"));
        assert!(!valid_code("-acme"));
        assert!(!valid_code("Acme"));
        assert!(!valid_code(""));
        assert!(valid_path("home.htm"));
        assert!(valid_path("site/_header.htm"));
        assert!(!valid_path("/home.htm"));
        assert!(!valid_path("../home.htm"));
        assert!(!valid_path("site//home.htm"));
        assert!(!valid_path("Home.htm"));
    }

    #[test]
    fn a_config_parses_and_refuses_unknown_keys() {
        let config = ThemeConfig::parse("name: Acme\nauthor: Acme\nparent: base\n").unwrap();
        assert_eq!(config.name, "Acme");
        assert_eq!(config.parent.as_deref(), Some("base"));
        assert!(ThemeConfig::parse("name: Acme\ncolour: red\n").is_err());
    }

    #[test]
    fn kinds_round_trip() {
        for kind in Kind::ALL {
            assert_eq!(kind.as_str().parse::<Kind>().unwrap(), kind);
        }
        assert!("widget".parse::<Kind>().is_err());
    }
}