Skip to main content

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    /// Cached base URL (host + base_path), computed once after construction.
40    #[serde(skip)]
41    pub cached_base_url: String,
42}
43
44/// Site-wide configuration.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct SiteConfig {
47    /// Site title.
48    pub title: String,
49
50    /// Host URL for the site (e.g., "https://example.com").
51    /// This is the origin without any path.
52    pub host: String,
53
54    /// Base path for subdirectory deployments (e.g., "/typstify").
55    /// Empty string for root deployments.
56    #[serde(default)]
57    pub base_path: String,
58
59    /// Default language code.
60    #[serde(default = "default_language")]
61    pub default_language: String,
62
63    /// Site description for meta tags.
64    #[serde(default)]
65    pub description: Option<String>,
66
67    /// Site author name.
68    #[serde(default)]
69    pub author: Option<String>,
70}
71
72/// Configuration for a specific language.
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74pub struct LanguageConfig {
75    /// Display name of the language (e.g., "中文", "日本語").
76    #[serde(default)]
77    pub name: Option<String>,
78
79    /// Override site title for this language.
80    #[serde(default)]
81    pub title: Option<String>,
82
83    /// Override site description for this language.
84    #[serde(default)]
85    pub description: Option<String>,
86}
87
88/// Build configuration.
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct BuildConfig {
91    /// Output directory for generated site.
92    #[serde(default = "default_output_dir")]
93    pub output_dir: String,
94
95    /// Whether to minify HTML output.
96    #[serde(default)]
97    pub minify: bool,
98
99    /// Syntax highlighting theme name.
100    #[serde(default = "default_syntax_theme")]
101    pub syntax_theme: String,
102
103    /// Whether to generate drafts.
104    #[serde(default)]
105    pub drafts: bool,
106
107    /// Whether to sanitize raw HTML in markdown output.
108    #[serde(default)]
109    pub sanitize_html: bool,
110}
111
112/// Search configuration.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct SearchConfig {
115    /// Whether search is enabled.
116    #[serde(default = "default_true")]
117    pub enabled: bool,
118
119    /// Fields to include in search index.
120    #[serde(default = "default_index_fields")]
121    pub index_fields: Vec<String>,
122
123    /// Chunk size for index splitting (bytes).
124    #[serde(default = "default_chunk_size")]
125    pub chunk_size: usize,
126}
127
128/// RSS feed configuration.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct RssConfig {
131    /// Whether RSS feed is enabled.
132    #[serde(default = "default_true")]
133    pub enabled: bool,
134
135    /// Maximum number of items in feed.
136    #[serde(default = "default_rss_limit")]
137    pub limit: usize,
138}
139
140/// Robots.txt configuration.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct RobotsConfig {
143    /// Whether robots.txt generation is enabled.
144    #[serde(default = "default_true")]
145    pub enabled: bool,
146
147    /// Disallowed paths.
148    #[serde(default)]
149    pub disallow: Vec<String>,
150
151    /// Allowed paths.
152    #[serde(default)]
153    pub allow: Vec<String>,
154}
155
156/// Taxonomy configuration.
157#[derive(Debug, Clone, Default, Serialize, Deserialize)]
158pub struct TaxonomyConfig {
159    /// Tags taxonomy settings.
160    #[serde(default)]
161    pub tags: TaxonomySettings,
162
163    /// Categories taxonomy settings.
164    #[serde(default)]
165    pub categories: TaxonomySettings,
166}
167
168/// Settings for a single taxonomy.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct TaxonomySettings {
171    /// Number of items per page.
172    #[serde(default = "default_paginate")]
173    pub paginate: usize,
174}
175
176// Default value functions
177fn default_language() -> String {
178    "en".to_string()
179}
180
181fn default_output_dir() -> String {
182    "public".to_string()
183}
184
185fn default_syntax_theme() -> String {
186    "base16-ocean.dark".to_string()
187}
188
189fn default_true() -> bool {
190    true
191}
192
193fn default_index_fields() -> Vec<String> {
194    vec!["title".to_string(), "body".to_string(), "tags".to_string()]
195}
196
197fn default_chunk_size() -> usize {
198    65536 // 64KB
199}
200
201fn default_rss_limit() -> usize {
202    20
203}
204
205fn default_paginate() -> usize {
206    10
207}
208
209impl Default for BuildConfig {
210    fn default() -> Self {
211        Self {
212            output_dir: default_output_dir(),
213            minify: false,
214            syntax_theme: default_syntax_theme(),
215            drafts: false,
216            sanitize_html: false,
217        }
218    }
219}
220
221impl Default for SearchConfig {
222    fn default() -> Self {
223        Self {
224            enabled: true,
225            index_fields: default_index_fields(),
226            chunk_size: default_chunk_size(),
227        }
228    }
229}
230
231impl Default for RssConfig {
232    fn default() -> Self {
233        Self {
234            enabled: true,
235            limit: default_rss_limit(),
236        }
237    }
238}
239
240impl Default for RobotsConfig {
241    fn default() -> Self {
242        Self {
243            enabled: true,
244            disallow: Vec::new(),
245            allow: Vec::new(),
246        }
247    }
248}
249
250impl Default for TaxonomySettings {
251    fn default() -> Self {
252        Self {
253            paginate: default_paginate(),
254        }
255    }
256}
257
258impl Config {
259    /// Compute the cached base URL from host and base_path.
260    fn compute_base_url(site: &SiteConfig) -> String {
261        let host = site.host.trim_end_matches('/');
262        let base_path = site.base_path.trim_end_matches('/');
263        format!("{host}{base_path}")
264    }
265
266    /// Load configuration from a TOML file.
267    pub fn load(path: &Path) -> Result<Self> {
268        if !path.exists() {
269            return Err(CoreError::config(format!(
270                "Configuration file not found: {}",
271                path.display()
272            )));
273        }
274
275        let content = std::fs::read_to_string(path)?;
276        let mut config: Config = toml::from_str(&content).map_err(|e| {
277            CoreError::config_with_source(
278                format!("Failed to parse config file: {}", path.display()),
279                e,
280            )
281        })?;
282
283        config.validate()?;
284        config.cached_base_url = Self::compute_base_url(&config.site);
285        Ok(config)
286    }
287
288    /// Load configuration using the config crate for more flexibility.
289    pub fn load_with_env(path: &Path) -> Result<Self> {
290        let settings = config::Config::builder()
291            .add_source(config::File::from(path))
292            .add_source(config::Environment::with_prefix("TYPSTIFY").separator("__"))
293            .build()?;
294
295        let mut config: Config = settings.try_deserialize()?;
296        config.validate()?;
297        config.cached_base_url = Self::compute_base_url(&config.site);
298        Ok(config)
299    }
300
301    /// Validate the configuration.
302    fn validate(&self) -> Result<()> {
303        if self.site.title.is_empty() {
304            return Err(CoreError::config("site.title cannot be empty"));
305        }
306
307        if self.site.host.is_empty() {
308            return Err(CoreError::config("site.host cannot be empty"));
309        }
310
311        // Ensure host doesn't have trailing slash
312        if self.site.host.ends_with('/') {
313            tracing::warn!("site.host should not have a trailing slash");
314        }
315
316        // Ensure base_path starts with / if not empty
317        if !self.site.base_path.is_empty() && !self.site.base_path.starts_with('/') {
318            tracing::warn!("site.base_path should start with /");
319        }
320
321        Ok(())
322    }
323
324    /// Get the full base URL (host + base_path).
325    #[must_use]
326    pub fn base_url(&self) -> &str {
327        &self.cached_base_url
328    }
329
330    /// Get the full URL for a path.
331    pub fn url_for(&self, path: &str) -> String {
332        let base = self.base_url();
333        let path = path.trim_start_matches('/');
334        format!("{base}/{path}")
335    }
336
337    /// Get the base path for URL generation.
338    /// Returns the configured base_path, ensuring no trailing slash.
339    #[must_use]
340    pub fn base_path(&self) -> &str {
341        self.site.base_path.trim_end_matches('/')
342    }
343
344    /// Check if a language code is configured (either as default or in languages map).
345    #[must_use]
346    pub fn has_language(&self, lang: &str) -> bool {
347        lang == self.site.default_language || self.languages.contains_key(lang)
348    }
349
350    /// Get all configured language codes.
351    #[must_use]
352    pub fn all_languages(&self) -> Vec<&str> {
353        let mut langs: Vec<&str> = vec![self.site.default_language.as_str()];
354        for lang in self.languages.keys() {
355            if lang != &self.site.default_language {
356                langs.push(lang.as_str());
357            }
358        }
359        langs
360    }
361
362    /// Get language-specific title, falling back to site title.
363    #[must_use]
364    pub fn title_for_language(&self, lang: &str) -> &str {
365        self.languages
366            .get(lang)
367            .and_then(|lc| lc.title.as_deref())
368            .unwrap_or(&self.site.title)
369    }
370
371    /// Get language-specific description, falling back to site description.
372    #[must_use]
373    pub fn description_for_language(&self, lang: &str) -> Option<&str> {
374        self.languages
375            .get(lang)
376            .and_then(|lc| lc.description.as_deref())
377            .or(self.site.description.as_deref())
378    }
379
380    /// Get display name for a language code.
381    #[must_use]
382    pub fn language_name<'a>(&'a self, lang: &'a str) -> &'a str {
383        self.languages
384            .get(lang)
385            .and_then(|lc| lc.name.as_deref())
386            .unwrap_or(lang)
387    }
388
389    /// Construct a Config from its public parts.
390    #[must_use]
391    pub fn from_parts(
392        site: SiteConfig,
393        build: BuildConfig,
394        search: SearchConfig,
395        rss: RssConfig,
396        robots: RobotsConfig,
397        taxonomies: TaxonomyConfig,
398        languages: HashMap<String, LanguageConfig>,
399    ) -> Self {
400        let cached_base_url = Self::compute_base_url(&site);
401        Self {
402            site,
403            build,
404            search,
405            rss,
406            robots,
407            taxonomies,
408            languages,
409            cached_base_url,
410        }
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use std::io::Write;
417
418    use super::*;
419
420    fn create_test_config() -> String {
421        r#"
422[site]
423title = "Test Site"
424host = "https://example.com"
425default_language = "en"
426
427[languages.zh]
428name = "中文"
429title = "测试站点"
430description = "一个测试站点"
431
432[build]
433output_dir = "dist"
434minify = true
435syntax_theme = "OneHalfDark"
436
437[search]
438enabled = true
439chunk_size = 32768
440
441[rss]
442limit = 15
443
444[taxonomies.tags]
445paginate = 20
446"#
447        .to_string()
448    }
449
450    #[test]
451    fn test_load_config() {
452        let dir = tempfile::tempdir().expect("create temp dir");
453        let config_path = dir.path().join("config.toml");
454        let mut file = std::fs::File::create(&config_path).expect("create file");
455        file.write_all(create_test_config().as_bytes())
456            .expect("write");
457
458        let config = Config::load(&config_path).expect("load config");
459
460        assert_eq!(config.site.title, "Test Site");
461        assert_eq!(config.site.host, "https://example.com");
462        assert_eq!(config.site.default_language, "en");
463        assert!(config.has_language("en"));
464        assert!(config.has_language("zh"));
465        assert!(!config.has_language("ja"));
466        assert_eq!(config.title_for_language("zh"), "测试站点");
467        assert_eq!(config.title_for_language("en"), "Test Site");
468        assert_eq!(config.language_name("zh"), "中文");
469        assert_eq!(config.build.output_dir, "dist");
470        assert!(config.build.minify);
471        assert_eq!(config.build.syntax_theme, "OneHalfDark");
472        assert!(config.search.enabled);
473        assert_eq!(config.search.chunk_size, 32768);
474        assert_eq!(config.rss.limit, 15);
475        assert_eq!(config.taxonomies.tags.paginate, 20);
476    }
477
478    #[test]
479    fn test_config_defaults() {
480        let dir = tempfile::tempdir().expect("create temp dir");
481        let config_path = dir.path().join("config.toml");
482        let minimal_config = r#"
483[site]
484title = "Minimal Site"
485host = "https://example.com"
486"#;
487        std::fs::write(&config_path, minimal_config).expect("write");
488
489        let config = Config::load(&config_path).expect("load config");
490
491        assert_eq!(config.site.default_language, "en");
492        assert_eq!(config.build.output_dir, "public");
493        assert!(!config.build.minify);
494        assert!(config.search.enabled);
495        assert_eq!(config.search.chunk_size, 65536);
496        assert_eq!(config.rss.limit, 20);
497    }
498
499    #[test]
500    fn test_config_validation_empty_title() {
501        let dir = tempfile::tempdir().expect("create temp dir");
502        let config_path = dir.path().join("config.toml");
503        let config_content = r#"
504[site]
505title = ""
506host = "https://example.com"
507"#;
508        std::fs::write(&config_path, config_content).expect("write");
509
510        let result = Config::load(&config_path);
511        assert!(result.is_err());
512        assert!(
513            result
514                .unwrap_err()
515                .to_string()
516                .contains("title cannot be empty")
517        );
518    }
519
520    #[test]
521    fn test_config_not_found() {
522        let result = Config::load(Path::new("/nonexistent/config.toml"));
523        assert!(result.is_err());
524        assert!(result.unwrap_err().to_string().contains("not found"));
525    }
526
527    #[test]
528    fn test_base_path_empty() {
529        let dir = tempfile::tempdir().expect("create temp dir");
530        let config_path = dir.path().join("config.toml");
531        let config_content = r#"
532[site]
533title = "Test"
534host = "https://example.com"
535"#;
536        std::fs::write(&config_path, config_content).expect("write");
537        let config = Config::load(&config_path).expect("load");
538        assert_eq!(config.base_path(), "");
539        assert_eq!(config.base_url(), "https://example.com");
540    }
541
542    #[test]
543    fn test_base_path_subdirectory() {
544        let dir = tempfile::tempdir().expect("create temp dir");
545        let config_path = dir.path().join("config.toml");
546        let config_content = r#"
547[site]
548title = "Test"
549host = "https://longcipher.github.io"
550base_path = "/typstify"
551"#;
552        std::fs::write(&config_path, config_content).expect("write");
553        let config = Config::load(&config_path).expect("load");
554        assert_eq!(config.base_path(), "/typstify");
555        assert_eq!(config.base_url(), "https://longcipher.github.io/typstify");
556    }
557
558    #[test]
559    fn test_base_path_with_trailing_slash() {
560        let dir = tempfile::tempdir().expect("create temp dir");
561        let config_path = dir.path().join("config.toml");
562        let config_content = r#"
563[site]
564title = "Test"
565host = "https://longcipher.github.io/"
566base_path = "/typstify/"
567"#;
568        std::fs::write(&config_path, config_content).expect("write");
569        let config = Config::load(&config_path).expect("load");
570        assert_eq!(config.base_path(), "/typstify");
571        assert_eq!(config.base_url(), "https://longcipher.github.io/typstify");
572    }
573
574    #[test]
575    fn test_load_with_env_override() {
576        let dir = tempfile::tempdir().expect("create temp dir");
577        let config_path = dir.path().join("config.toml");
578        let mut file = std::fs::File::create(&config_path).expect("create file");
579        file.write_all(create_test_config().as_bytes())
580            .expect("write");
581
582        // Temporarily set env var to override site.title
583        // SAFETY: tests run single-threaded; env var is cleaned up after assertion.
584        unsafe { std::env::set_var("TYPSTIFY__SITE__TITLE", "Env Override Title") };
585
586        let config = Config::load_with_env(&config_path).expect("load config with env");
587
588        assert_eq!(config.site.title, "Env Override Title");
589
590        // Clean up env var
591        unsafe { std::env::remove_var("TYPSTIFY__SITE__TITLE") };
592    }
593
594    #[test]
595    fn test_url_for() {
596        let dir = tempfile::tempdir().expect("create temp dir");
597        let config_path = dir.path().join("config.toml");
598        let config_content = r#"
599[site]
600title = "Test"
601host = "https://example.com"
602base_path = "/blog"
603"#;
604        std::fs::write(&config_path, config_content).expect("write");
605        let config = Config::load(&config_path).expect("load");
606        assert_eq!(
607            config.url_for("/posts/hello"),
608            "https://example.com/blog/posts/hello"
609        );
610        assert_eq!(
611            config.url_for("posts/hello"),
612            "https://example.com/blog/posts/hello"
613        );
614    }
615}