Skip to main content

laterite_web/theme/
mod.rs

1//! Themes: the public site's templates, stored as rows and edited from the
2//! admin.
3//!
4//! A theme is a code, a YAML config ([`ThemeConfig`]) and a set of templates
5//! ([`Template`]), each a kind ([`Kind`]), a path, a settings bag and markup. A
6//! theme may name a parent; a path its own rows lack is looked up through the
7//! parent chain ([`store::resolve_template`]). One theme is active for the
8//! site ([`ThemeSettings`]); an operator may hold a different one to edit and
9//! preview ([`EditTheme`]). Every save of a template keeps what it replaced as
10//! a [`Revision`], so an edit can be undone.
11//!
12//! Register [`ThemeModule`] with the bootstrap and its tables migrate with the
13//! application's.
14//!
15//! ```no_run
16//! use laterite_web::theme::{self, Kind, ThemeModule};
17//! # async fn f(db: laterite_core::Db) -> Result<(), theme::ThemeError> {
18//! let site = theme::store::create_theme(&db, "acme", "name: Acme\nauthor: Acme\n").await?;
19//! theme::store::save_template(&db, site.id, Kind::Layout, "default.htm", None, "<html>{% block body %}{% endblock %}</html>", None).await?;
20//! theme::set_active_theme(&db, "acme").await?;
21//! # Ok(()) }
22//! ```
23
24pub mod admin;
25pub mod archive;
26pub mod build;
27pub mod component;
28pub mod engine;
29pub mod maintenance;
30pub mod markup;
31mod migrations;
32pub mod page;
33pub mod router;
34mod schema;
35pub mod site;
36pub mod store;
37
38pub use engine::{check, variant, Engine, EngineCache, RenderError};
39
40/// A media record as a template sees it: what a component hands over so
41/// `media(file)`, `media(file, preset="card")` and `file.url|variant("card")`
42/// work, beside `title`, `alt`, `width`, `height`, `kind` and `name`.
43pub fn media_value(record: &laterite_media::MediaRecord) -> serde_json::Value {
44    serde_json::json!({
45        "id": record.id,
46        "url": laterite_admin::medialib::file_url(record),
47        "name": record.display_name(),
48        "title": record.title,
49        "alt": record.alt,
50        "width": record.width,
51        "height": record.height,
52        "kind": record.kind().as_str(),
53        "content_type": record.content_type,
54    })
55}
56
57use laterite_admin::form::FormConfig;
58use laterite_admin::settings::{PreferencesModel, SettingsModel};
59use laterite_core::{Db, MigrationSet, Module, ModuleId};
60use serde::{Deserialize, Serialize};
61use std::fmt;
62use std::str::FromStr;
63
64/// The theme module: its tables migrate with the application's, and it
65/// registers the Themes screen under `web.manage_themes`.
66pub struct ThemeModule;
67
68impl Module for ThemeModule {
69    fn id(&self) -> ModuleId {
70        ModuleId::new(migrations::MODULE_ID)
71    }
72    fn migrations(&self) -> MigrationSet {
73        migrations::migrations()
74    }
75    /// The Themes tab sits at the panel's top level: `/admin/themes`.
76    fn admin_base(&self) -> Option<&'static str> {
77        Some("/")
78    }
79    /// The Themes screen and its permission.
80    fn register(&self, registry: &mut laterite_core::Registry) {
81        use laterite_admin::AdminRegistry;
82        registry.add_permission(admin::permission());
83        registry.add_permission(maintenance::permission());
84        registry.add_settings(maintenance::settings_item());
85        registry.add_screen(admin::screen());
86        registry.add_public_route(site::route());
87    }
88}
89
90/// The keys of the `theme` value a template reads that belong to the theme
91/// itself; a settings field cannot take one.
92pub const RESERVED_KEYS: [&str; 6] = [
93    "code",
94    "name",
95    "author",
96    "homepage",
97    "description",
98    "preview",
99];
100
101/// A theme's config: the YAML an operator edits, parsed on every save.
102///
103/// `form` declares the theme's own settings fields; `parent` names the theme
104/// whose templates fill in for the ones this theme lacks; `require` lists the
105/// modules the theme's templates need.
106#[derive(Debug, Clone, Default, Serialize, Deserialize)]
107#[serde(default, deny_unknown_fields)]
108pub struct ThemeConfig {
109    pub name: String,
110    pub author: String,
111    pub homepage: String,
112    pub description: String,
113    /// A URL for the theme's preview image, shown on the theme list; unset,
114    /// the list shows the theme's initials.
115    pub preview: Option<String>,
116    pub parent: Option<String>,
117    pub require: Vec<String>,
118    pub form: Option<FormConfig>,
119}
120
121impl ThemeConfig {
122    /// Parses a theme's YAML config. A `form` field named like one of the
123    /// `theme` value's own keys (`code`, `name`, `author`, `homepage`,
124    /// `description`) is refused, since the settings render beside them.
125    pub fn parse(yaml: &str) -> Result<Self, ThemeError> {
126        let config: Self =
127            serde_saphyr::from_str(yaml).map_err(|e| ThemeError::Config(e.to_string()))?;
128        if let Some(form) = &config.form {
129            if let Some(field) = form
130                .fields
131                .iter()
132                .find(|f| RESERVED_KEYS.contains(&f.name.as_str()))
133            {
134                return Err(ThemeError::Config(format!(
135                    "the form field {:?} is reserved for the theme itself",
136                    field.name
137                )));
138            }
139        }
140        Ok(config)
141    }
142
143    /// Renders the config as YAML, the form a new theme's row starts from.
144    pub fn to_yaml(&self) -> Result<String, ThemeError> {
145        serde_saphyr::to_string(self).map_err(|e| ThemeError::Config(e.to_string()))
146    }
147}
148
149/// What a template is to the site.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
151#[serde(rename_all = "lowercase")]
152pub enum Kind {
153    /// The frame a page renders inside.
154    Layout,
155    /// A page the router serves at its URL.
156    Page,
157    /// A fragment a layout or page includes.
158    Partial,
159    /// A block of content (HTML, Markdown or text) a template places.
160    Content,
161    /// A text asset the site serves as a file (a stylesheet, a script).
162    Asset,
163}
164
165impl Kind {
166    /// Every kind, in the order the editor lists them.
167    pub const ALL: [Kind; 5] = [
168        Kind::Layout,
169        Kind::Page,
170        Kind::Partial,
171        Kind::Content,
172        Kind::Asset,
173    ];
174
175    /// The kind's stored name.
176    pub fn as_str(self) -> &'static str {
177        match self {
178            Kind::Layout => "layout",
179            Kind::Page => "page",
180            Kind::Partial => "partial",
181            Kind::Content => "content",
182            Kind::Asset => "asset",
183        }
184    }
185}
186
187impl fmt::Display for Kind {
188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189        f.write_str(self.as_str())
190    }
191}
192
193impl FromStr for Kind {
194    type Err = ThemeError;
195    fn from_str(s: &str) -> Result<Self, ThemeError> {
196        Kind::ALL
197            .into_iter()
198            .find(|k| k.as_str() == s)
199            .ok_or_else(|| ThemeError::Kind(s.to_string()))
200    }
201}
202
203/// A theme row, with its config parsed.
204#[derive(Debug, Clone)]
205pub struct Theme {
206    pub id: i64,
207    pub code: String,
208    /// The parent's code, mirrored from the config so a lookup never parses.
209    pub parent: Option<String>,
210    /// A locked theme refuses template saves and deletions.
211    pub locked: bool,
212    pub config: ThemeConfig,
213    /// The YAML as the operator wrote it.
214    pub config_source: String,
215    /// The theme's own settings values: a JSON object keyed by the fields of
216    /// the `form` in force (its own, else the nearest ancestor's). Empty
217    /// until saved.
218    pub settings: serde_json::Value,
219    pub created_at: String,
220    pub updated_at: String,
221}
222
223/// A template row.
224#[derive(Debug, Clone)]
225pub struct Template {
226    pub id: i64,
227    pub theme_id: i64,
228    pub kind: Kind,
229    pub path: String,
230    /// The settings bag (a page's URL and layout, a layout's description).
231    pub settings: serde_json::Value,
232    pub markup: String,
233    pub created_at: String,
234    pub updated_at: String,
235}
236
237/// What a template held before a save replaced it.
238#[derive(Debug, Clone)]
239pub struct Revision {
240    pub id: i64,
241    pub template_id: i64,
242    pub settings: serde_json::Value,
243    pub markup: String,
244    pub saved_at: String,
245    /// The operator whose save made this the previous content, when known.
246    pub saved_by: Option<i64>,
247}
248
249/// What the theme store can refuse or fail on.
250#[derive(Debug, thiserror::Error)]
251pub enum ThemeError {
252    #[error("theme storage error")]
253    Db(#[from] sqlx::Error),
254    #[error("the theme config does not parse: {0}")]
255    Config(String),
256    #[error("{0:?} is not a theme code: lowercase letters, digits and hyphens, starting with a letter or digit")]
257    Code(String),
258    #[error("{0:?} is not a template path: segments of lowercase letters, digits, dots, hyphens and underscores")]
259    Path(String),
260    #[error("{0:?} is not a template kind")]
261    Kind(String),
262    #[error("the theme {0:?} is locked")]
263    Locked(String),
264    #[error("no theme {0:?}")]
265    NotFound(String),
266    #[error("no template {0:?}")]
267    TemplateNotFound(String),
268    #[error("the theme {0:?} already exists")]
269    Exists(String),
270    #[error("{0}")]
271    Parent(String),
272    #[error("the theme {0:?} is active")]
273    Active(String),
274    #[error("the template does not compile")]
275    Template(#[source] engine::RenderError),
276    #[error("{0}")]
277    Page(String),
278    #[error("archive error")]
279    Zip(#[from] zip::result::ZipError),
280    #[error("archive error")]
281    Io(#[from] std::io::Error),
282    #[error("settings error")]
283    Settings(#[from] laterite_admin::settings::SettingsError),
284    #[error("preferences error")]
285    Preferences(#[from] laterite_admin::settings::PreferencesError),
286}
287
288/// The site's theme settings: which theme is active.
289#[derive(Debug, Clone, Default, Serialize, Deserialize)]
290#[serde(default)]
291pub struct ThemeSettings {
292    pub active: Option<String>,
293}
294
295impl SettingsModel for ThemeSettings {
296    const CODE: &'static str = "laterite.theme";
297}
298
299/// An operator's own choice of theme to edit and preview.
300#[derive(Debug, Clone, Default, Serialize, Deserialize)]
301#[serde(default)]
302pub struct EditTheme {
303    pub code: Option<String>,
304}
305
306impl PreferencesModel for EditTheme {
307    const CODE: &'static str = "laterite.theme.edit";
308}
309
310/// The active theme, or none when no theme is set or the set one is gone.
311pub async fn active_theme(db: &Db) -> Result<Option<Theme>, ThemeError> {
312    let settings: ThemeSettings = laterite_admin::settings::load(db).await?;
313    match settings.active {
314        Some(code) => store::find_theme(db, &code).await,
315        None => Ok(None),
316    }
317}
318
319/// Makes `code` the site's theme. The theme must exist.
320pub async fn set_active_theme(db: &Db, code: &str) -> Result<(), ThemeError> {
321    store::find_theme(db, code)
322        .await?
323        .ok_or_else(|| ThemeError::NotFound(code.to_string()))?;
324    let settings = ThemeSettings {
325        active: Some(code.to_string()),
326    };
327    laterite_admin::settings::save(db, &settings).await?;
328    Ok(())
329}
330
331/// The theme an operator edits: their own choice when it still exists, else
332/// the active theme.
333pub async fn edit_theme(db: &Db, user_id: i64) -> Result<Option<Theme>, ThemeError> {
334    let own: EditTheme = laterite_admin::settings::load_for(db, user_id).await?;
335    if let Some(code) = own.code {
336        if let Some(theme) = store::find_theme(db, &code).await? {
337            return Ok(Some(theme));
338        }
339    }
340    active_theme(db).await
341}
342
343/// Sets, or with `None` clears, the theme an operator edits.
344pub async fn set_edit_theme(db: &Db, user_id: i64, code: Option<&str>) -> Result<(), ThemeError> {
345    if let Some(code) = code {
346        store::find_theme(db, code)
347            .await?
348            .ok_or_else(|| ThemeError::NotFound(code.to_string()))?;
349    }
350    let own = EditTheme {
351        code: code.map(str::to_string),
352    };
353    laterite_admin::settings::save_for(db, user_id, &own).await?;
354    Ok(())
355}
356
357/// A theme code: lowercase letters, digits and hyphens, starting with a letter
358/// or digit, at most 64 characters.
359pub fn valid_code(code: &str) -> bool {
360    let mut chars = code.chars();
361    match chars.next() {
362        Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {}
363        _ => return false,
364    }
365    code.len() <= 64 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
366}
367
368/// A template path: `/`-separated segments of lowercase letters, digits, dots,
369/// hyphens and underscores, no empty, `.` or `..` segment, at most 255 characters.
370pub fn valid_path(path: &str) -> bool {
371    !path.is_empty()
372        && path.len() <= 255
373        && path.split('/').all(|seg| {
374            !seg.is_empty()
375                && seg != "."
376                && seg != ".."
377                && seg.chars().all(|c| {
378                    c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '-' | '_')
379                })
380        })
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn codes_and_paths_are_checked() {
389        assert!(valid_code("acme"));
390        assert!(valid_code("acme-2024"));
391        assert!(!valid_code("-acme"));
392        assert!(!valid_code("Acme"));
393        assert!(!valid_code(""));
394        assert!(valid_path("home.htm"));
395        assert!(valid_path("site/_header.htm"));
396        assert!(!valid_path("/home.htm"));
397        assert!(!valid_path("../home.htm"));
398        assert!(!valid_path("site//home.htm"));
399        assert!(!valid_path("Home.htm"));
400    }
401
402    #[test]
403    fn a_config_parses_and_refuses_unknown_keys() {
404        let config = ThemeConfig::parse("name: Acme\nauthor: Acme\nparent: base\n").unwrap();
405        assert_eq!(config.name, "Acme");
406        assert_eq!(config.parent.as_deref(), Some("base"));
407        assert!(ThemeConfig::parse("name: Acme\ncolour: red\n").is_err());
408    }
409
410    #[test]
411    fn kinds_round_trip() {
412        for kind in Kind::ALL {
413            assert_eq!(kind.as_str().parse::<Kind>().unwrap(), kind);
414        }
415        assert!("widget".parse::<Kind>().is_err());
416    }
417}