1pub 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
40pub 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 fn admin_base(&self) -> Option<&'static str> {
53 Some("/")
54 }
55 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
63pub const RESERVED_KEYS: [&str; 5] = ["code", "name", "author", "homepage", "description"];
66
67#[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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
114#[serde(rename_all = "lowercase")]
115pub enum Kind {
116 Layout,
118 Page,
120 Partial,
122 Content,
124 Asset,
126}
127
128impl Kind {
129 pub const ALL: [Kind; 5] = [
131 Kind::Layout,
132 Kind::Page,
133 Kind::Partial,
134 Kind::Content,
135 Kind::Asset,
136 ];
137
138 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#[derive(Debug, Clone)]
168pub struct Theme {
169 pub id: i64,
170 pub code: String,
171 pub parent: Option<String>,
173 pub locked: bool,
175 pub config: ThemeConfig,
176 pub config_source: String,
178 pub settings: serde_json::Value,
182 pub created_at: String,
183 pub updated_at: String,
184}
185
186#[derive(Debug, Clone)]
188pub struct Template {
189 pub id: i64,
190 pub theme_id: i64,
191 pub kind: Kind,
192 pub path: String,
193 pub settings: serde_json::Value,
195 pub markup: String,
196 pub created_at: String,
197 pub updated_at: String,
198}
199
200#[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 pub saved_by: Option<i64>,
210}
211
212#[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#[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#[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
271pub 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
280pub 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
292pub 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
304pub 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
318pub 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
329pub 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}