1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5#[derive(Debug, Clone, Deserialize, Serialize)]
6pub struct Config {
7 pub site: SiteConfig,
8 pub server: ServerConfig,
9 pub database: DatabaseConfig,
10 pub content: ContentConfig,
11 pub media: MediaConfig,
12 pub theme: ThemeConfig,
13 pub auth: AuthConfig,
14 #[serde(default)]
15 pub audit: AuditConfig,
16 #[serde(default)]
17 pub homepage: HomepageConfig,
18 #[serde(default)]
19 pub api: ApiConfig,
20 #[serde(default)]
21 pub backup: BackupConfig,
22}
23
24#[derive(Debug, Clone, Deserialize, Serialize)]
25pub struct SiteConfig {
26 pub title: String,
27 pub description: String,
28 pub url: String,
29 #[serde(default = "default_language")]
30 pub language: String,
31}
32
33#[derive(Debug, Clone, Deserialize, Serialize)]
34pub struct ServerConfig {
35 #[serde(default = "default_host")]
36 pub host: String,
37 #[serde(default = "default_port")]
38 pub port: u16,
39}
40
41#[derive(Debug, Clone, Deserialize, Serialize)]
42pub struct DatabaseConfig {
43 pub path: String,
44 #[serde(default = "default_pool_size")]
45 pub pool_size: u32,
46}
47
48#[derive(Debug, Clone, Deserialize, Serialize)]
49pub struct ContentConfig {
50 #[serde(default = "default_posts_per_page")]
51 pub posts_per_page: usize,
52 #[serde(default = "default_excerpt_length")]
53 pub excerpt_length: usize,
54 #[serde(default = "default_true")]
55 pub auto_excerpt: bool,
56 #[serde(default = "default_version_retention")]
58 pub version_retention: usize,
59}
60
61#[derive(Debug, Clone, Deserialize, Serialize)]
62pub struct MediaConfig {
63 pub upload_dir: String,
64 #[serde(default = "default_max_upload")]
65 pub max_upload_size: String,
66}
67
68#[derive(Debug, Clone, Deserialize, Serialize)]
69pub struct ThemeConfig {
70 #[serde(default = "default_theme")]
71 pub name: String,
72 #[serde(default)]
73 pub custom: CustomThemeOptions,
74}
75
76#[derive(Debug, Clone, Default, Deserialize, Serialize)]
77pub struct CustomThemeOptions {
78 pub primary_color: Option<String>,
79 pub primary_color_hover: Option<String>,
80 pub accent_color: Option<String>,
81 pub background_color: Option<String>,
82 pub background_secondary: Option<String>,
83 pub text_color: Option<String>,
84 pub text_muted: Option<String>,
85 pub border_color: Option<String>,
86 pub link_color: Option<String>,
87 pub font_family: Option<String>,
88 pub heading_font_family: Option<String>,
89 pub font_size: Option<String>,
90 pub heading_scale: Option<f32>,
91 pub line_height: Option<f32>,
92 pub border_radius: Option<String>,
93}
94
95impl CustomThemeOptions {
96 pub fn to_css_variables(&self) -> String {
97 let mut vars = Vec::new();
98
99 if let Some(ref v) = self.primary_color {
100 vars.push(format!("--color-primary: {};", v));
101 vars.push(format!("--color-primary-light: {}1a;", v));
102 }
103 if let Some(ref v) = self.primary_color_hover {
104 vars.push(format!("--color-primary-hover: {};", v));
105 }
106 if let Some(ref v) = self.accent_color {
107 vars.push(format!("--color-accent: {};", v));
108 }
109 if let Some(ref v) = self.background_color {
110 vars.push(format!("--bg: {};", v));
111 }
112 if let Some(ref v) = self.background_secondary {
113 vars.push(format!("--bg-secondary: {};", v));
114 }
115 if let Some(ref v) = self.text_color {
116 vars.push(format!("--text: {};", v));
117 }
118 if let Some(ref v) = self.text_muted {
119 vars.push(format!("--text-muted: {};", v));
120 }
121 if let Some(ref v) = self.border_color {
122 vars.push(format!("--border: {};", v));
123 }
124 if let Some(ref v) = self.link_color {
125 vars.push(format!("--color-link: {};", v));
126 }
127 if let Some(ref v) = self.font_family {
128 vars.push(format!("--font-sans: {};", v));
129 }
130 if let Some(ref v) = self.heading_font_family {
131 vars.push(format!("--font-display: {};", v));
132 }
133 if let Some(ref v) = self.font_size {
134 vars.push(format!("--font-size-base: {};", v));
135 }
136 if let Some(v) = self.line_height {
137 vars.push(format!("--line-height-normal: {};", v));
138 }
139 if let Some(ref v) = self.border_radius {
140 vars.push(format!("--radius: {};", v));
141 vars.push(format!("--radius-sm: {};", v));
142 vars.push(format!("--radius-lg: {};", v));
143 }
144
145 vars.join("\n ")
146 }
147
148 pub fn has_customizations(&self) -> bool {
149 self.primary_color.is_some()
150 || self.primary_color_hover.is_some()
151 || self.accent_color.is_some()
152 || self.background_color.is_some()
153 || self.background_secondary.is_some()
154 || self.text_color.is_some()
155 || self.text_muted.is_some()
156 || self.border_color.is_some()
157 || self.link_color.is_some()
158 || self.font_family.is_some()
159 || self.heading_font_family.is_some()
160 || self.font_size.is_some()
161 || self.heading_scale.is_some()
162 || self.line_height.is_some()
163 || self.border_radius.is_some()
164 }
165}
166
167impl ThemeConfig {
168 pub const AVAILABLE_THEMES: [&'static str; 15] = [
169 "default", "minimal", "magazine", "brutalist", "neon",
170 "serif", "ocean", "midnight", "botanical", "monochrome",
171 "coral", "terminal", "nordic", "sunset", "typewriter",
172 ];
173
174 pub fn validate(&self) -> Result<()> {
175 if !Self::AVAILABLE_THEMES.contains(&self.name.as_str()) {
176 anyhow::bail!(
177 "Invalid theme '{}'. Available themes: {}",
178 self.name,
179 Self::AVAILABLE_THEMES.join(", ")
180 );
181 }
182 Ok(())
183 }
184
185 pub fn is_valid_theme(name: &str) -> bool {
186 Self::AVAILABLE_THEMES.contains(&name)
187 }
188}
189
190#[derive(Debug, Clone, Deserialize, Serialize)]
191pub struct AuthConfig {
192 #[serde(default = "default_session_lifetime")]
193 pub session_lifetime: String,
194}
195
196#[derive(Debug, Clone, Deserialize, Serialize)]
197pub struct AuditConfig {
198 #[serde(default = "default_audit_enabled")]
199 pub enabled: bool,
200 #[serde(default = "default_audit_retention_days")]
201 pub retention_days: u32,
202 #[serde(default = "default_audit_log_auth")]
203 pub log_auth_events: bool,
204 #[serde(default)]
205 pub log_content_views: bool,
206}
207
208impl Default for AuditConfig {
209 fn default() -> Self {
210 Self {
211 enabled: default_audit_enabled(),
212 retention_days: default_audit_retention_days(),
213 log_auth_events: default_audit_log_auth(),
214 log_content_views: false,
215 }
216 }
217}
218
219#[derive(Debug, Clone, Deserialize, Serialize)]
220pub struct HomepageConfig {
221 #[serde(default = "default_hero_layout")]
222 pub hero_layout: String,
223 #[serde(default)]
224 pub hero_image: Option<String>,
225 #[serde(default = "default_hero_height")]
226 pub hero_height: String,
227 #[serde(default = "default_hero_text_align")]
228 pub hero_text_align: String,
229 #[serde(default = "default_true")]
230 pub show_hero: bool,
231 #[serde(default = "default_true")]
232 pub show_pages: bool,
233 #[serde(default = "default_true")]
234 pub show_posts: bool,
235 #[serde(default = "default_posts_layout")]
236 pub posts_layout: String,
237 #[serde(default = "default_posts_columns")]
238 pub posts_columns: u8,
239 #[serde(default = "default_pages_layout")]
240 pub pages_layout: String,
241 #[serde(default)]
242 pub sections_order: Vec<String>,
243}
244
245impl Default for HomepageConfig {
246 fn default() -> Self {
247 Self {
248 hero_layout: default_hero_layout(),
249 hero_image: None,
250 hero_height: default_hero_height(),
251 hero_text_align: default_hero_text_align(),
252 show_hero: true,
253 show_pages: true,
254 show_posts: true,
255 posts_layout: default_posts_layout(),
256 posts_columns: default_posts_columns(),
257 pages_layout: default_pages_layout(),
258 sections_order: Vec::new(),
259 }
260 }
261}
262
263impl HomepageConfig {
264 pub fn get_sections_order(&self) -> Vec<&str> {
265 if self.sections_order.is_empty() {
266 vec!["hero", "pages", "posts"]
267 } else {
268 self.sections_order.iter().map(|s| s.as_str()).collect()
269 }
270 }
271}
272
273#[derive(Debug, Clone, Deserialize, Serialize)]
274pub struct ApiConfig {
275 #[serde(default = "default_false")]
276 pub enabled: bool,
277 #[serde(default = "default_api_page_size")]
278 pub default_page_size: usize,
279 #[serde(default = "default_api_max_page_size")]
280 pub max_page_size: usize,
281}
282
283impl Default for ApiConfig {
284 fn default() -> Self {
285 Self {
286 enabled: false,
287 default_page_size: default_api_page_size(),
288 max_page_size: default_api_max_page_size(),
289 }
290 }
291}
292
293#[derive(Debug, Clone, Deserialize, Serialize)]
294pub struct BackupConfig {
295 #[serde(default)]
296 pub auto_enabled: bool,
297 #[serde(default = "default_backup_interval")]
298 pub interval_hours: u64,
299 #[serde(default = "default_backup_retention")]
300 pub retention_count: usize,
301 #[serde(default = "default_backup_dir")]
302 pub directory: String,
303}
304
305impl Default for BackupConfig {
306 fn default() -> Self {
307 Self {
308 auto_enabled: false,
309 interval_hours: default_backup_interval(),
310 retention_count: default_backup_retention(),
311 directory: default_backup_dir(),
312 }
313 }
314}
315
316fn default_hero_layout() -> String {
317 "centered".to_string()
318}
319
320fn default_hero_height() -> String {
321 "medium".to_string()
322}
323
324fn default_hero_text_align() -> String {
325 "center".to_string()
326}
327
328fn default_posts_layout() -> String {
329 "grid".to_string()
330}
331
332fn default_posts_columns() -> u8 {
333 2
334}
335
336fn default_pages_layout() -> String {
337 "grid".to_string()
338}
339
340fn default_language() -> String {
341 "en".to_string()
342}
343
344fn default_host() -> String {
345 "127.0.0.1".to_string()
346}
347
348fn default_port() -> u16 {
349 3000
350}
351
352fn default_posts_per_page() -> usize {
353 10
354}
355
356fn default_pool_size() -> u32 {
357 10
358}
359
360fn default_excerpt_length() -> usize {
361 200
362}
363
364fn default_true() -> bool {
365 true
366}
367
368fn default_max_upload() -> String {
369 "10MB".to_string()
370}
371
372fn default_theme() -> String {
373 "default".to_string()
374}
375
376fn default_session_lifetime() -> String {
377 "7d".to_string()
378}
379
380fn default_version_retention() -> usize {
381 50
382}
383
384fn default_audit_enabled() -> bool {
385 true
386}
387
388fn default_audit_retention_days() -> u32 {
389 90
390}
391
392fn default_audit_log_auth() -> bool {
393 true
394}
395
396fn default_false() -> bool {
397 false
398}
399
400fn default_api_page_size() -> usize {
401 20
402}
403
404fn default_api_max_page_size() -> usize {
405 100
406}
407
408fn default_backup_interval() -> u64 {
409 24
410}
411
412fn default_backup_retention() -> usize {
413 7
414}
415
416fn default_backup_dir() -> String {
417 "./backups".to_string()
418}
419
420impl Config {
421 pub fn load(path: &Path) -> Result<Self> {
422 let content = std::fs::read_to_string(path).map_err(|e| {
423 anyhow::anyhow!(
424 "Could not read config file '{}': {}. Are you in a Pebble site directory?",
425 path.display(),
426 e
427 )
428 })?;
429 let config: Config = toml::from_str(&content)?;
430 config.validate()?;
431 Ok(config)
432 }
433
434 pub fn validate(&self) -> Result<()> {
435 if self.content.posts_per_page == 0 {
436 anyhow::bail!("content.posts_per_page must be greater than 0");
437 }
438 if self.content.posts_per_page > 100 {
439 anyhow::bail!("content.posts_per_page must be 100 or less");
440 }
441 if self.content.excerpt_length == 0 {
442 anyhow::bail!("content.excerpt_length must be greater than 0");
443 }
444 if self.content.excerpt_length > 10000 {
445 anyhow::bail!("content.excerpt_length must be 10000 or less");
446 }
447 self.theme.validate()?;
448 Ok(())
449 }
450}