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> {
78 Some("/")
79 }
80 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
92pub const RESERVED_KEYS: [&str; 6] = [
95 "code",
96 "name",
97 "author",
98 "homepage",
99 "description",
100 "preview",
101];
102
103#[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 pub preview: Option<String>,
118 pub parent: Option<String>,
119 pub require: Vec<String>,
120 pub form: Option<FormConfig>,
121}
122
123impl ThemeConfig {
124 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
153#[serde(rename_all = "lowercase")]
154pub enum Kind {
155 Layout,
157 Page,
159 Partial,
161 Content,
163 Asset,
165}
166
167impl Kind {
168 pub const ALL: [Kind; 5] = [
170 Kind::Layout,
171 Kind::Page,
172 Kind::Partial,
173 Kind::Content,
174 Kind::Asset,
175 ];
176
177 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#[derive(Debug, Clone)]
207pub struct Theme {
208 pub id: i64,
209 pub code: String,
210 pub parent: Option<String>,
212 pub locked: bool,
214 pub config: ThemeConfig,
215 pub config_source: String,
217 pub settings: serde_json::Value,
221 pub created_at: String,
222 pub updated_at: String,
223}
224
225#[derive(Debug, Clone)]
227pub struct Template {
228 pub id: i64,
229 pub theme_id: i64,
230 pub kind: Kind,
231 pub path: String,
232 pub settings: serde_json::Value,
234 pub markup: String,
235 pub created_at: String,
236 pub updated_at: String,
237}
238
239#[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 pub saved_by: Option<i64>,
249}
250
251#[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#[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#[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
312pub 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
321pub 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
333pub 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
345pub 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
359pub 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
370pub 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}