typstify_core/
config.rs

1//! Site configuration management.
2
3use std::{collections::HashMap, path::Path};
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::{CoreError, Result};
8
9/// Main configuration structure for Typstify.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Config {
12    /// Site-wide settings.
13    pub site: SiteConfig,
14
15    /// Build settings.
16    #[serde(default)]
17    pub build: BuildConfig,
18
19    /// Search settings.
20    #[serde(default)]
21    pub search: SearchConfig,
22
23    /// RSS feed settings.
24    #[serde(default)]
25    pub rss: RssConfig,
26
27    /// Robots.txt settings.
28    #[serde(default)]
29    pub robots: RobotsConfig,
30
31    /// Taxonomy settings.
32    #[serde(default)]
33    pub taxonomies: TaxonomyConfig,
34
35    /// Language-specific configurations.
36    #[serde(default)]
37    pub languages: HashMap<String, LanguageConfig>,
38}
39
40/// Site-wide configuration.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct SiteConfig {
43    /// Site title.
44    pub title: String,
45
46    /// Base URL for the site (e.g., "https://example.com").
47    pub base_url: String,
48
49    /// Default language code.
50    #[serde(default = "default_language")]
51    pub default_language: String,
52
53    /// Site description for meta tags.
54    #[serde(default)]
55    pub description: Option<String>,
56
57    /// Site author name.
58    #[serde(default)]
59    pub author: Option<String>,
60}
61
62/// Configuration for a specific language.
63#[derive(Debug, Clone, Default, Serialize, Deserialize)]
64pub struct LanguageConfig {
65    /// Display name of the language (e.g., "中文", "日本語").
66    #[serde(default)]
67    pub name: Option<String>,
68
69    /// Override site title for this language.
70    #[serde(default)]
71    pub title: Option<String>,
72
73    /// Override site description for this language.
74    #[serde(default)]
75    pub description: Option<String>,
76}
77
78/// Build configuration.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct BuildConfig {
81    /// Output directory for generated site.
82    #[serde(default = "default_output_dir")]
83    pub output_dir: String,
84
85    /// Whether to minify HTML output.
86    #[serde(default)]
87    pub minify: bool,
88
89    /// Syntax highlighting theme name.
90    #[serde(default = "default_syntax_theme")]
91    pub syntax_theme: String,
92
93    /// Whether to generate drafts.
94    #[serde(default)]
95    pub drafts: bool,
96}
97
98/// Search configuration.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct SearchConfig {
101    /// Whether search is enabled.
102    #[serde(default = "default_true")]
103    pub enabled: bool,
104
105    /// Fields to include in search index.
106    #[serde(default = "default_index_fields")]
107    pub index_fields: Vec<String>,
108
109    /// Chunk size for index splitting (bytes).
110    #[serde(default = "default_chunk_size")]
111    pub chunk_size: usize,
112}
113
114/// RSS feed configuration.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct RssConfig {
117    /// Whether RSS feed is enabled.
118    #[serde(default = "default_true")]
119    pub enabled: bool,
120
121    /// Maximum number of items in feed.
122    #[serde(default = "default_rss_limit")]
123    pub limit: usize,
124}
125
126/// Robots.txt configuration.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct RobotsConfig {
129    /// Whether robots.txt generation is enabled.
130    #[serde(default = "default_true")]
131    pub enabled: bool,
132
133    /// Disallowed paths.
134    #[serde(default)]
135    pub disallow: Vec<String>,
136
137    /// Allowed paths.
138    #[serde(default)]
139    pub allow: Vec<String>,
140}
141
142/// Taxonomy configuration.
143#[derive(Debug, Clone, Default, Serialize, Deserialize)]
144pub struct TaxonomyConfig {
145    /// Tags taxonomy settings.
146    #[serde(default)]
147    pub tags: TaxonomySettings,
148
149    /// Categories taxonomy settings.
150    #[serde(default)]
151    pub categories: TaxonomySettings,
152}
153
154/// Settings for a single taxonomy.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct TaxonomySettings {
157    /// Number of items per page.
158    #[serde(default = "default_paginate")]
159    pub paginate: usize,
160}
161
162// Default value functions
163fn default_language() -> String {
164    "en".to_string()
165}
166
167fn default_output_dir() -> String {
168    "public".to_string()
169}
170
171fn default_syntax_theme() -> String {
172    "base16-ocean.dark".to_string()
173}
174
175fn default_true() -> bool {
176    true
177}
178
179fn default_index_fields() -> Vec<String> {
180    vec!["title".to_string(), "body".to_string(), "tags".to_string()]
181}
182
183fn default_chunk_size() -> usize {
184    65536 // 64KB
185}
186
187fn default_rss_limit() -> usize {
188    20
189}
190
191fn default_paginate() -> usize {
192    10
193}
194
195impl Default for BuildConfig {
196    fn default() -> Self {
197        Self {
198            output_dir: default_output_dir(),
199            minify: false,
200            syntax_theme: default_syntax_theme(),
201            drafts: false,
202        }
203    }
204}
205
206impl Default for SearchConfig {
207    fn default() -> Self {
208        Self {
209            enabled: true,
210            index_fields: default_index_fields(),
211            chunk_size: default_chunk_size(),
212        }
213    }
214}
215
216impl Default for RssConfig {
217    fn default() -> Self {
218        Self {
219            enabled: true,
220            limit: default_rss_limit(),
221        }
222    }
223}
224
225impl Default for RobotsConfig {
226    fn default() -> Self {
227        Self {
228            enabled: true,
229            disallow: Vec::new(),
230            allow: Vec::new(),
231        }
232    }
233}
234
235impl Default for TaxonomySettings {
236    fn default() -> Self {
237        Self {
238            paginate: default_paginate(),
239        }
240    }
241}
242
243impl Config {
244    /// Load configuration from a TOML file.
245    pub fn load(path: &Path) -> Result<Self> {
246        if !path.exists() {
247            return Err(CoreError::config(format!(
248                "Configuration file not found: {}",
249                path.display()
250            )));
251        }
252
253        let content = std::fs::read_to_string(path)?;
254        let config: Config = toml::from_str(&content).map_err(|e| {
255            CoreError::config_with_source(
256                format!("Failed to parse config file: {}", path.display()),
257                e,
258            )
259        })?;
260
261        config.validate()?;
262        Ok(config)
263    }
264
265    /// Load configuration using the config crate for more flexibility.
266    pub fn load_with_env(path: &Path) -> Result<Self> {
267        let settings = config::Config::builder()
268            .add_source(config::File::from(path))
269            .add_source(config::Environment::with_prefix("TYPSTIFY").separator("__"))
270            .build()?;
271
272        let config: Config = settings.try_deserialize()?;
273        config.validate()?;
274        Ok(config)
275    }
276
277    /// Validate the configuration.
278    fn validate(&self) -> Result<()> {
279        if self.site.title.is_empty() {
280            return Err(CoreError::config("site.title cannot be empty"));
281        }
282
283        if self.site.base_url.is_empty() {
284            return Err(CoreError::config("site.base_url cannot be empty"));
285        }
286
287        // Ensure base_url doesn't have trailing slash
288        if self.site.base_url.ends_with('/') {
289            tracing::warn!("site.base_url should not have a trailing slash");
290        }
291
292        Ok(())
293    }
294
295    /// Get the full URL for a path.
296    pub fn url_for(&self, path: &str) -> String {
297        let base = self.site.base_url.trim_end_matches('/');
298        let path = path.trim_start_matches('/');
299        format!("{base}/{path}")
300    }
301
302    /// Check if a language code is configured (either as default or in languages map).
303    #[must_use]
304    pub fn has_language(&self, lang: &str) -> bool {
305        lang == self.site.default_language || self.languages.contains_key(lang)
306    }
307
308    /// Get all configured language codes.
309    #[must_use]
310    pub fn all_languages(&self) -> Vec<&str> {
311        let mut langs: Vec<&str> = vec![self.site.default_language.as_str()];
312        for lang in self.languages.keys() {
313            if lang != &self.site.default_language {
314                langs.push(lang.as_str());
315            }
316        }
317        langs
318    }
319
320    /// Get language-specific title, falling back to site title.
321    #[must_use]
322    pub fn title_for_language(&self, lang: &str) -> &str {
323        self.languages
324            .get(lang)
325            .and_then(|lc| lc.title.as_deref())
326            .unwrap_or(&self.site.title)
327    }
328
329    /// Get language-specific description, falling back to site description.
330    #[must_use]
331    pub fn description_for_language(&self, lang: &str) -> Option<&str> {
332        self.languages
333            .get(lang)
334            .and_then(|lc| lc.description.as_deref())
335            .or(self.site.description.as_deref())
336    }
337
338    /// Get display name for a language code.
339    #[must_use]
340    pub fn language_name<'a>(&'a self, lang: &'a str) -> &'a str {
341        self.languages
342            .get(lang)
343            .and_then(|lc| lc.name.as_deref())
344            .unwrap_or(lang)
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use std::io::Write;
351
352    use super::*;
353
354    fn create_test_config() -> String {
355        r#"
356[site]
357title = "Test Site"
358base_url = "https://example.com"
359default_language = "en"
360
361[languages.zh]
362name = "中文"
363title = "测试站点"
364description = "一个测试站点"
365
366[build]
367output_dir = "dist"
368minify = true
369syntax_theme = "OneHalfDark"
370
371[search]
372enabled = true
373chunk_size = 32768
374
375[rss]
376limit = 15
377
378[taxonomies.tags]
379paginate = 20
380"#
381        .to_string()
382    }
383
384    #[test]
385    fn test_load_config() {
386        let dir = tempfile::tempdir().expect("create temp dir");
387        let config_path = dir.path().join("config.toml");
388        let mut file = std::fs::File::create(&config_path).expect("create file");
389        file.write_all(create_test_config().as_bytes())
390            .expect("write");
391
392        let config = Config::load(&config_path).expect("load config");
393
394        assert_eq!(config.site.title, "Test Site");
395        assert_eq!(config.site.base_url, "https://example.com");
396        assert_eq!(config.site.default_language, "en");
397        assert!(config.has_language("en"));
398        assert!(config.has_language("zh"));
399        assert!(!config.has_language("ja"));
400        assert_eq!(config.title_for_language("zh"), "测试站点");
401        assert_eq!(config.title_for_language("en"), "Test Site");
402        assert_eq!(config.language_name("zh"), "中文");
403        assert_eq!(config.build.output_dir, "dist");
404        assert!(config.build.minify);
405        assert_eq!(config.build.syntax_theme, "OneHalfDark");
406        assert!(config.search.enabled);
407        assert_eq!(config.search.chunk_size, 32768);
408        assert_eq!(config.rss.limit, 15);
409        assert_eq!(config.taxonomies.tags.paginate, 20);
410    }
411
412    #[test]
413    fn test_config_defaults() {
414        let dir = tempfile::tempdir().expect("create temp dir");
415        let config_path = dir.path().join("config.toml");
416        let minimal_config = r#"
417[site]
418title = "Minimal Site"
419base_url = "https://example.com"
420"#;
421        std::fs::write(&config_path, minimal_config).expect("write");
422
423        let config = Config::load(&config_path).expect("load config");
424
425        assert_eq!(config.site.default_language, "en");
426        assert_eq!(config.build.output_dir, "public");
427        assert!(!config.build.minify);
428        assert!(config.search.enabled);
429        assert_eq!(config.search.chunk_size, 65536);
430        assert_eq!(config.rss.limit, 20);
431    }
432
433    #[test]
434    fn test_url_for() {
435        let dir = tempfile::tempdir().expect("create temp dir");
436        let config_path = dir.path().join("config.toml");
437        let config_content = r#"
438[site]
439title = "Test"
440base_url = "https://example.com"
441"#;
442        std::fs::write(&config_path, config_content).expect("write");
443
444        let config = Config::load(&config_path).expect("load config");
445
446        assert_eq!(
447            config.url_for("/posts/hello"),
448            "https://example.com/posts/hello"
449        );
450        assert_eq!(
451            config.url_for("posts/hello"),
452            "https://example.com/posts/hello"
453        );
454    }
455
456    #[test]
457    fn test_config_validation_empty_title() {
458        let dir = tempfile::tempdir().expect("create temp dir");
459        let config_path = dir.path().join("config.toml");
460        let config_content = r#"
461[site]
462title = ""
463base_url = "https://example.com"
464"#;
465        std::fs::write(&config_path, config_content).expect("write");
466
467        let result = Config::load(&config_path);
468        assert!(result.is_err());
469        assert!(
470            result
471                .unwrap_err()
472                .to_string()
473                .contains("title cannot be empty")
474        );
475    }
476
477    #[test]
478    fn test_config_not_found() {
479        let result = Config::load(Path::new("/nonexistent/config.toml"));
480        assert!(result.is_err());
481        assert!(result.unwrap_err().to_string().contains("not found"));
482    }
483}