1use std::{collections::HashMap, path::Path};
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::{CoreError, Result};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Config {
12 pub site: SiteConfig,
14
15 #[serde(default)]
17 pub build: BuildConfig,
18
19 #[serde(default)]
21 pub search: SearchConfig,
22
23 #[serde(default)]
25 pub rss: RssConfig,
26
27 #[serde(default)]
29 pub robots: RobotsConfig,
30
31 #[serde(default)]
33 pub taxonomies: TaxonomyConfig,
34
35 #[serde(default)]
37 pub languages: HashMap<String, LanguageConfig>,
38
39 #[serde(skip)]
41 pub cached_base_url: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct SiteConfig {
47 pub title: String,
49
50 pub host: String,
53
54 #[serde(default)]
57 pub base_path: String,
58
59 #[serde(default = "default_language")]
61 pub default_language: String,
62
63 #[serde(default)]
65 pub description: Option<String>,
66
67 #[serde(default)]
69 pub author: Option<String>,
70}
71
72#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74pub struct LanguageConfig {
75 #[serde(default)]
77 pub name: Option<String>,
78
79 #[serde(default)]
81 pub title: Option<String>,
82
83 #[serde(default)]
85 pub description: Option<String>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct BuildConfig {
91 #[serde(default = "default_output_dir")]
93 pub output_dir: String,
94
95 #[serde(default)]
97 pub minify: bool,
98
99 #[serde(default = "default_syntax_theme")]
101 pub syntax_theme: String,
102
103 #[serde(default)]
105 pub drafts: bool,
106
107 #[serde(default)]
109 pub sanitize_html: bool,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct SearchConfig {
115 #[serde(default = "default_true")]
117 pub enabled: bool,
118
119 #[serde(default = "default_index_fields")]
121 pub index_fields: Vec<String>,
122
123 #[serde(default = "default_chunk_size")]
125 pub chunk_size: usize,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct RssConfig {
131 #[serde(default = "default_true")]
133 pub enabled: bool,
134
135 #[serde(default = "default_rss_limit")]
137 pub limit: usize,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct RobotsConfig {
143 #[serde(default = "default_true")]
145 pub enabled: bool,
146
147 #[serde(default)]
149 pub disallow: Vec<String>,
150
151 #[serde(default)]
153 pub allow: Vec<String>,
154}
155
156#[derive(Debug, Clone, Default, Serialize, Deserialize)]
158pub struct TaxonomyConfig {
159 #[serde(default)]
161 pub tags: TaxonomySettings,
162
163 #[serde(default)]
165 pub categories: TaxonomySettings,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct TaxonomySettings {
171 #[serde(default = "default_paginate")]
173 pub paginate: usize,
174}
175
176fn 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 }
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 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 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 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 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 if self.site.host.ends_with('/') {
313 tracing::warn!("site.host should not have a trailing slash");
314 }
315
316 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 #[must_use]
326 pub fn base_url(&self) -> &str {
327 &self.cached_base_url
328 }
329
330 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 #[must_use]
340 pub fn base_path(&self) -> &str {
341 self.site.base_path.trim_end_matches('/')
342 }
343
344 #[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 #[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 #[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 #[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 #[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 #[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 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 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}