1pub 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
40pub 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
64pub 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 fn admin_base(&self) -> Option<&'static str> {
77 Some("/")
78 }
79 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
90pub const RESERVED_KEYS: [&str; 6] = [
93 "code",
94 "name",
95 "author",
96 "homepage",
97 "description",
98 "preview",
99];
100
101#[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 pub preview: Option<String>,
116 pub parent: Option<String>,
117 pub require: Vec<String>,
118 pub form: Option<FormConfig>,
119}
120
121impl ThemeConfig {
122 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
151#[serde(rename_all = "lowercase")]
152pub enum Kind {
153 Layout,
155 Page,
157 Partial,
159 Content,
161 Asset,
163}
164
165impl Kind {
166 pub const ALL: [Kind; 5] = [
168 Kind::Layout,
169 Kind::Page,
170 Kind::Partial,
171 Kind::Content,
172 Kind::Asset,
173 ];
174
175 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#[derive(Debug, Clone)]
205pub struct Theme {
206 pub id: i64,
207 pub code: String,
208 pub parent: Option<String>,
210 pub locked: bool,
212 pub config: ThemeConfig,
213 pub config_source: String,
215 pub settings: serde_json::Value,
219 pub created_at: String,
220 pub updated_at: String,
221}
222
223#[derive(Debug, Clone)]
225pub struct Template {
226 pub id: i64,
227 pub theme_id: i64,
228 pub kind: Kind,
229 pub path: String,
230 pub settings: serde_json::Value,
232 pub markup: String,
233 pub created_at: String,
234 pub updated_at: String,
235}
236
237#[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 pub saved_by: Option<i64>,
247}
248
249#[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#[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#[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
310pub 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
319pub 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
331pub 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
343pub 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
357pub 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
368pub 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}