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