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