pub mod admin;
pub mod archive;
pub mod build;
pub mod engine;
mod migrations;
pub mod page;
pub mod router;
mod schema;
pub mod site;
pub mod store;
pub use engine::{check, Engine, EngineCache, RenderError};
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;
pub struct ThemeModule;
impl Module for ThemeModule {
fn id(&self) -> ModuleId {
ModuleId::new(migrations::MODULE_ID)
}
fn migrations(&self) -> MigrationSet {
migrations::migrations()
}
fn admin_base(&self) -> Option<&'static str> {
Some("/")
}
fn register(&self, registry: &mut laterite_core::Registry) {
use laterite_admin::AdminRegistry;
registry.add_permission(admin::permission());
registry.add_screen(admin::screen());
registry.add_public_route(site::route());
}
}
pub const RESERVED_KEYS: [&str; 5] = ["code", "name", "author", "homepage", "description"];
#[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,
pub parent: Option<String>,
pub require: Vec<String>,
pub form: Option<FormConfig>,
}
impl ThemeConfig {
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)
}
pub fn to_yaml(&self) -> Result<String, ThemeError> {
serde_saphyr::to_string(self).map_err(|e| ThemeError::Config(e.to_string()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Layout,
Page,
Partial,
Content,
Asset,
}
impl Kind {
pub const ALL: [Kind; 5] = [
Kind::Layout,
Kind::Page,
Kind::Partial,
Kind::Content,
Kind::Asset,
];
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()))
}
}
#[derive(Debug, Clone)]
pub struct Theme {
pub id: i64,
pub code: String,
pub parent: Option<String>,
pub locked: bool,
pub config: ThemeConfig,
pub config_source: String,
pub settings: serde_json::Value,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone)]
pub struct Template {
pub id: i64,
pub theme_id: i64,
pub kind: Kind,
pub path: String,
pub settings: serde_json::Value,
pub markup: String,
pub created_at: String,
pub updated_at: String,
}
#[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,
pub saved_by: Option<i64>,
}
#[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),
}
#[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";
}
#[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";
}
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),
}
}
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(())
}
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
}
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(())
}
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 == '-')
}
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());
}
}