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(v) = self.heading_scale {
140 vars.push(format!("--font-size-xl: {:.3}rem;", 1.25 * v));
141 vars.push(format!("--font-size-2xl: {:.3}rem;", 1.5 * v));
142 vars.push(format!("--font-size-3xl: {:.3}rem;", 2.0 * v));
143 vars.push(format!("--font-size-4xl: {:.3}rem;", 2.25 * v));
144 }
145 if let Some(ref v) = self.border_radius {
146 vars.push(format!("--radius: {};", v));
147 vars.push(format!("--radius-sm: {};", v));
148 vars.push(format!("--radius-lg: {};", v));
149 }
150
151 vars.join("\n ")
152 }
153
154 pub fn has_customizations(&self) -> bool {
155 self.primary_color.is_some()
156 || self.primary_color_hover.is_some()
157 || self.accent_color.is_some()
158 || self.background_color.is_some()
159 || self.background_secondary.is_some()
160 || self.text_color.is_some()
161 || self.text_muted.is_some()
162 || self.border_color.is_some()
163 || self.link_color.is_some()
164 || self.font_family.is_some()
165 || self.heading_font_family.is_some()
166 || self.font_size.is_some()
167 || self.heading_scale.is_some()
168 || self.line_height.is_some()
169 || self.border_radius.is_some()
170 }
171}
172
173impl ThemeConfig {
174 pub const AVAILABLE_THEMES: [&'static str; 15] = [
175 "default", "minimal", "magazine", "brutalist", "neon",
176 "serif", "ocean", "midnight", "botanical", "monochrome",
177 "coral", "terminal", "nordic", "sunset", "typewriter",
178 ];
179
180 pub fn validate(&self) -> Result<()> {
181 if !Self::AVAILABLE_THEMES.contains(&self.name.as_str()) {
182 anyhow::bail!(
183 "Invalid theme '{}'. Available themes: {}",
184 self.name,
185 Self::AVAILABLE_THEMES.join(", ")
186 );
187 }
188 Ok(())
189 }
190
191 pub fn is_valid_theme(name: &str) -> bool {
192 Self::AVAILABLE_THEMES.contains(&name)
193 }
194}
195
196#[derive(Debug, Clone, Deserialize, Serialize)]
197pub struct AuthConfig {
198 #[serde(default = "default_session_lifetime")]
199 pub session_lifetime: String,
200}
201
202#[derive(Debug, Clone, Deserialize, Serialize)]
203pub struct AuditConfig {
204 #[serde(default = "default_audit_enabled")]
205 pub enabled: bool,
206 #[serde(default = "default_audit_retention_days")]
207 pub retention_days: u32,
208 #[serde(default = "default_audit_log_auth")]
209 pub log_auth_events: bool,
210 #[serde(default)]
211 pub log_content_views: bool,
212}
213
214impl Default for AuditConfig {
215 fn default() -> Self {
216 Self {
217 enabled: default_audit_enabled(),
218 retention_days: default_audit_retention_days(),
219 log_auth_events: default_audit_log_auth(),
220 log_content_views: false,
221 }
222 }
223}
224
225#[derive(Debug, Clone, Deserialize, Serialize)]
226pub struct HomepageConfig {
227 #[serde(default = "default_hero_layout")]
228 pub hero_layout: String,
229 #[serde(default)]
230 pub hero_image: Option<String>,
231 #[serde(default = "default_hero_height")]
232 pub hero_height: String,
233 #[serde(default = "default_hero_text_align")]
234 pub hero_text_align: String,
235 #[serde(default = "default_true")]
236 pub show_hero: bool,
237 #[serde(default = "default_true")]
238 pub show_pages: bool,
239 #[serde(default = "default_true")]
240 pub show_posts: bool,
241 #[serde(default = "default_posts_layout")]
242 pub posts_layout: String,
243 #[serde(default = "default_posts_columns")]
244 pub posts_columns: u8,
245 #[serde(default = "default_pages_layout")]
246 pub pages_layout: String,
247 #[serde(default)]
248 pub sections_order: Vec<String>,
249}
250
251impl Default for HomepageConfig {
252 fn default() -> Self {
253 Self {
254 hero_layout: default_hero_layout(),
255 hero_image: None,
256 hero_height: default_hero_height(),
257 hero_text_align: default_hero_text_align(),
258 show_hero: true,
259 show_pages: true,
260 show_posts: true,
261 posts_layout: default_posts_layout(),
262 posts_columns: default_posts_columns(),
263 pages_layout: default_pages_layout(),
264 sections_order: Vec::new(),
265 }
266 }
267}
268
269impl HomepageConfig {
270 pub fn get_sections_order(&self) -> Vec<&str> {
271 if self.sections_order.is_empty() {
272 vec!["hero", "pages", "posts"]
273 } else {
274 self.sections_order.iter().map(|s| s.as_str()).collect()
275 }
276 }
277}
278
279#[derive(Debug, Clone, Deserialize, Serialize)]
280pub struct ApiConfig {
281 #[serde(default = "default_false")]
282 pub enabled: bool,
283 #[serde(default = "default_api_page_size")]
284 pub default_page_size: usize,
285 #[serde(default = "default_api_max_page_size")]
286 pub max_page_size: usize,
287}
288
289impl Default for ApiConfig {
290 fn default() -> Self {
291 Self {
292 enabled: false,
293 default_page_size: default_api_page_size(),
294 max_page_size: default_api_max_page_size(),
295 }
296 }
297}
298
299#[derive(Debug, Clone, Deserialize, Serialize)]
300pub struct BackupConfig {
301 #[serde(default)]
302 pub auto_enabled: bool,
303 #[serde(default = "default_backup_interval")]
304 pub interval_hours: u64,
305 #[serde(default = "default_backup_retention")]
306 pub retention_count: usize,
307 #[serde(default = "default_backup_dir")]
308 pub directory: String,
309}
310
311impl Default for BackupConfig {
312 fn default() -> Self {
313 Self {
314 auto_enabled: false,
315 interval_hours: default_backup_interval(),
316 retention_count: default_backup_retention(),
317 directory: default_backup_dir(),
318 }
319 }
320}
321
322fn default_hero_layout() -> String {
323 "centered".to_string()
324}
325
326fn default_hero_height() -> String {
327 "medium".to_string()
328}
329
330fn default_hero_text_align() -> String {
331 "center".to_string()
332}
333
334fn default_posts_layout() -> String {
335 "grid".to_string()
336}
337
338fn default_posts_columns() -> u8 {
339 2
340}
341
342fn default_pages_layout() -> String {
343 "grid".to_string()
344}
345
346fn default_language() -> String {
347 "en".to_string()
348}
349
350fn default_host() -> String {
351 "127.0.0.1".to_string()
352}
353
354fn default_port() -> u16 {
355 3000
356}
357
358fn default_posts_per_page() -> usize {
359 10
360}
361
362fn default_pool_size() -> u32 {
363 10
364}
365
366fn default_excerpt_length() -> usize {
367 200
368}
369
370fn default_true() -> bool {
371 true
372}
373
374fn default_max_upload() -> String {
375 "10MB".to_string()
376}
377
378impl MediaConfig {
379 pub fn max_upload_bytes(&self) -> usize {
382 let s = self.max_upload_size.trim().to_uppercase();
383 if let Some(gb) = s.strip_suffix("GB") {
384 gb.parse::<usize>().unwrap_or(1) * 1024 * 1024 * 1024
385 } else if let Some(mb) = s.strip_suffix("MB") {
386 mb.parse::<usize>().unwrap_or(10) * 1024 * 1024
387 } else if let Some(kb) = s.strip_suffix("KB") {
388 kb.parse::<usize>().unwrap_or(10240) * 1024
389 } else {
390 s.parse::<usize>().unwrap_or(10 * 1024 * 1024)
391 }
392 }
393}
394
395fn default_theme() -> String {
396 "default".to_string()
397}
398
399fn default_session_lifetime() -> String {
400 "7d".to_string()
401}
402
403impl AuthConfig {
404 pub fn session_lifetime_days(&self) -> i64 {
407 let s = self.session_lifetime.trim();
408 if let Some(days) = s.strip_suffix('d') {
409 days.parse::<i64>().unwrap_or(7)
410 } else if let Some(hours) = s.strip_suffix('h') {
411 let h = hours.parse::<i64>().unwrap_or(168);
412 (h + 23) / 24 } else {
414 s.parse::<i64>().unwrap_or(7)
415 }
416 }
417}
418
419fn default_version_retention() -> usize {
420 50
421}
422
423fn default_audit_enabled() -> bool {
424 true
425}
426
427fn default_audit_retention_days() -> u32 {
428 90
429}
430
431fn default_audit_log_auth() -> bool {
432 true
433}
434
435fn default_false() -> bool {
436 false
437}
438
439fn default_api_page_size() -> usize {
440 20
441}
442
443fn default_api_max_page_size() -> usize {
444 100
445}
446
447fn default_backup_interval() -> u64 {
448 24
449}
450
451fn default_backup_retention() -> usize {
452 7
453}
454
455fn default_backup_dir() -> String {
456 "./backups".to_string()
457}
458
459impl Config {
460 pub fn load(path: &Path) -> Result<Self> {
461 let content = std::fs::read_to_string(path).map_err(|e| {
462 anyhow::anyhow!(
463 "Could not read config file '{}': {}. Are you in a Pebble site directory?",
464 path.display(),
465 e
466 )
467 })?;
468 let config: Config = toml::from_str(&content)?;
469 config.validate()?;
470 Ok(config)
471 }
472
473 pub fn validate(&self) -> Result<()> {
474 if self.content.posts_per_page == 0 {
475 anyhow::bail!("content.posts_per_page must be greater than 0");
476 }
477 if self.content.posts_per_page > 100 {
478 anyhow::bail!("content.posts_per_page must be 100 or less");
479 }
480 if self.content.excerpt_length == 0 {
481 anyhow::bail!("content.excerpt_length must be greater than 0");
482 }
483 if self.content.excerpt_length > 10000 {
484 anyhow::bail!("content.excerpt_length must be 10000 or less");
485 }
486 self.theme.validate()?;
487 Ok(())
488 }
489}