1mod builtin_themes;
13
14use anyhow::{Context, Result};
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17
18pub use builtin_themes::{BUILTIN_BASE_CSS, BUILTIN_PREVIEW_CSS, BUILTIN_THEMES};
20
21#[derive(Debug, Clone)]
23pub struct Theme {
24 pub id: String,
26 pub name: String,
28 pub css: String,
30}
31
32pub struct ThemeRegistry {
34 themes: HashMap<String, Theme>,
35 base_css: String,
36}
37
38impl ThemeRegistry {
39 pub fn new() -> Result<Self> {
45 let mut registry = Self::from_builtins();
47
48 if let Ok(user_dir) = Self::user_themes_directory()
50 && user_dir.exists()
51 {
52 registry.load_user_themes(&user_dir)?;
53 }
54
55 Ok(registry)
56 }
57
58 fn from_builtins() -> Self {
60 let base_css = builtin_themes::BUILTIN_BASE_CSS.to_string();
61 let mut themes = HashMap::new();
62
63 for (id, theme_css) in builtin_themes::BUILTIN_THEMES {
64 let combined = format!("{}\n\n/* Theme: {} */\n{}", base_css, id, theme_css);
65 let name = Self::id_to_name(id);
66 themes.insert(
67 id.to_string(),
68 Theme {
69 id: id.to_string(),
70 name,
71 css: combined,
72 },
73 );
74 }
75
76 if !themes.contains_key("minimal") {
78 themes.insert(
79 "minimal".to_string(),
80 Theme {
81 id: "minimal".to_string(),
82 name: "Minimal".to_string(),
83 css: base_css.clone(),
84 },
85 );
86 }
87
88 Self { themes, base_css }
89 }
90
91 fn user_themes_directory() -> Result<PathBuf> {
93 let cwd = std::env::current_dir().context("Failed to get current directory")?;
94 Ok(cwd.join("templates/themes"))
95 }
96
97 fn load_user_themes(&mut self, dir: &Path) -> Result<()> {
99 let base_path = dir.join("_base.css");
101 if base_path.exists() {
102 self.base_css = std::fs::read_to_string(&base_path).with_context(|| {
103 format!("Failed to read user base CSS: {}", base_path.display())
104 })?;
105 }
106
107 for entry in std::fs::read_dir(dir)? {
109 let entry = entry?;
110 let path = entry.path();
111
112 if path.extension().is_some_and(|e| e == "css") {
113 let Some(stem) = path.file_stem() else {
114 continue;
115 };
116 let filename = stem.to_string_lossy().to_string();
117
118 if filename.starts_with('_') {
120 continue;
121 }
122
123 let theme_css = std::fs::read_to_string(&path)
124 .with_context(|| format!("Failed to read user theme: {}", path.display()))?;
125
126 let combined = format!(
128 "{}\n\n/* Theme: {} */\n{}",
129 self.base_css, filename, theme_css
130 );
131
132 let name = Self::id_to_name(&filename);
133 self.themes.insert(
135 filename.clone(),
136 Theme {
137 id: filename,
138 name,
139 css: combined,
140 },
141 );
142 }
143 }
144
145 Ok(())
146 }
147
148 fn id_to_name(id: &str) -> String {
150 match id {
151 "elegant" => "雅致".to_string(),
152 "tech" => "技术".to_string(),
153 "minimal" => "极简".to_string(),
154 "wechat-green" => "微信绿".to_string(),
155 other => other
156 .split('-')
157 .map(|w| {
158 let mut c = w.chars();
159 match c.next() {
160 None => String::new(),
161 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
162 }
163 })
164 .collect::<Vec<_>>()
165 .join(" "),
166 }
167 }
168
169 pub fn get(&self, id: &str) -> Option<&Theme> {
171 self.themes.get(id)
172 }
173
174 pub fn get_or_default(&self, id: &str) -> Result<&Theme> {
176 self.themes
177 .get(id)
178 .or_else(|| self.themes.get("minimal"))
179 .or_else(|| self.themes.values().next())
180 .ok_or_else(|| anyhow::anyhow!("theme registry has no themes"))
181 }
182
183 pub fn list(&self) -> Vec<&str> {
185 self.themes.keys().map(|s| s.as_str()).collect()
186 }
187
188 pub fn base_css(&self) -> &str {
190 &self.base_css
191 }
192
193 pub fn load_preview_css(&self, platform: &str) -> Option<String> {
202 if let Ok(user_dir) = Self::user_themes_directory() {
204 let preview_path = user_dir.join(format!("_preview-{}.css", platform));
205 if preview_path.exists()
206 && let Ok(css) = std::fs::read_to_string(&preview_path)
207 {
208 return Some(css);
209 }
210 }
211
212 builtin_themes::BUILTIN_PREVIEW_CSS
214 .iter()
215 .find(|(id, _)| *id == platform)
216 .map(|(_, css)| css.to_string())
217 }
218}
219
220pub fn resolve_theme(
230 platform_id: &str,
231 content: &typub_core::Content,
232 global_config: &typub_config::Config,
233 profile_default_theme: Option<&str>,
234 registry: &ThemeRegistry,
235 fallback: &Theme,
236) -> Theme {
237 let theme_id: Option<String> = content
239 .platform_config(platform_id)
240 .and_then(|c| c.get_str("theme"))
241 .or_else(|| content.meta.theme.as_deref().map(String::from))
243 .or_else(|| {
245 global_config
246 .platforms
247 .get(platform_id)
248 .and_then(|p| p.theme.as_deref().map(String::from))
249 })
250 .or_else(|| global_config.theme.as_deref().map(String::from))
252 .or_else(|| profile_default_theme.map(|s| s.to_string()));
254
255 if let Some(id) = theme_id
257 && let Some(theme) = registry.get(&id)
258 {
259 return theme.clone();
260 }
261
262 fallback.clone()
263}
264
265pub fn load_theme(
277 theme_id: Option<&str>,
278 profile_default: Option<&str>,
279 registry: &ThemeRegistry,
280 fallback: &Theme,
281) -> Theme {
282 if let Some(id) = theme_id
284 && let Some(theme) = registry.get(id)
285 {
286 return theme.clone();
287 }
288
289 if let Some(id) = profile_default
291 && let Some(theme) = registry.get(id)
292 {
293 return theme.clone();
294 }
295
296 fallback.clone()
298}
299
300pub fn apply_theme(html: &str, theme: &Theme, inline: bool) -> Result<String> {
313 if inline {
314 inline_css(html, &theme.css)
316 } else {
317 Ok(format!("<style>\n{}\n</style>\n{}", theme.css, html))
319 }
320}
321
322pub fn apply_theme_full_document(
329 html: &str,
330 theme: &Theme,
331 title: &str,
332 inline: bool,
333) -> Result<String> {
334 let (style_block, body) = if inline {
335 (String::new(), inline_css(html, &theme.css)?)
336 } else {
337 (
338 format!("<style>\n{}\n</style>", theme.css),
339 html.to_string(),
340 )
341 };
342
343 Ok(format!(
344 r#"<!DOCTYPE html>
345<html>
346<head>
347 <meta charset="utf-8">
348 <meta name="viewport" content="width=device-width, initial-scale=1">
349 <title>{}</title>
350 {}
351</head>
352<body>
353 {}
354</body>
355</html>"#,
356 title, style_block, body
357 ))
358}
359
360fn inline_css(html: &str, css: &str) -> Result<String> {
362 let full_html = format!(
364 r#"<!DOCTYPE html>
365<html>
366<head>
367<style>{}</style>
368</head>
369<body>
370{}
371</body>
372</html>"#,
373 css, html
374 );
375
376 let inliner = css_inline::CSSInliner::options()
378 .inline_style_tags(true)
379 .keep_style_tags(false)
380 .build();
381
382 let inlined = inliner.inline(&full_html).context("Failed to inline CSS")?;
383
384 if let Some(start) = inlined.find("<body>")
386 && let Some(end) = inlined.rfind("</body>")
387 {
388 return Ok(inlined[start + 6..end].trim().to_string());
389 }
390
391 Ok(inlined)
392}
393
394#[cfg(test)]
395mod tests {
396 #![allow(clippy::expect_used)]
397 use super::*;
398
399 #[test]
400 fn test_id_to_name() {
401 assert_eq!(ThemeRegistry::id_to_name("elegant"), "雅致");
402 assert_eq!(ThemeRegistry::id_to_name("tech"), "技术");
403 assert_eq!(
404 ThemeRegistry::id_to_name("my-custom-theme"),
405 "My Custom Theme"
406 );
407 }
408
409 #[test]
410 fn test_apply_theme_inline() {
411 let theme = Theme {
412 id: "test".to_string(),
413 name: "Test".to_string(),
414 css: "p { color: red; }".to_string(),
416 };
417
418 let html = "<p>Hello</p>";
419 let result = apply_theme(html, &theme, true).expect("apply theme");
420
421 assert!(result.contains("color"));
423 assert!(result.contains("Hello"));
424 }
425
426 #[test]
427 fn test_apply_theme_external() {
428 let theme = Theme {
429 id: "test".to_string(),
430 name: "Test".to_string(),
431 css: "p { color: red; }".to_string(),
433 };
434
435 let html = "<p>Hello</p>";
436 let result = apply_theme(html, &theme, false).expect("apply theme");
437
438 assert!(result.contains("<style>"));
440 assert!(result.contains("color: red"));
441 assert!(result.contains("Hello"));
442 }
443
444 #[test]
445 fn test_apply_theme_full_document_external() {
446 let theme = Theme {
447 id: "test".to_string(),
448 name: "Test".to_string(),
449 css: ".content p { color: blue; }".to_string(),
450 };
451 let html = "<p>Hello</p>";
452 let result = apply_theme_full_document(html, &theme, "My Title", false)
453 .expect("apply full document");
454
455 assert!(result.contains("<!DOCTYPE html>"));
456 assert!(result.contains("<title>My Title</title>"));
457 assert!(result.contains("<style>"));
458 assert!(result.contains("color: blue"));
459 assert!(result.contains("Hello"));
460 assert!(result.contains("</html>"));
461 }
462
463 #[test]
464 fn test_apply_theme_full_document_inline() {
465 let theme = Theme {
466 id: "test".to_string(),
467 name: "Test".to_string(),
468 css: ".content p { color: green; }".to_string(),
469 };
470 let html = "<p>World</p>";
471 let result =
472 apply_theme_full_document(html, &theme, "Inline Title", true).expect("apply inline");
473
474 assert!(result.contains("<!DOCTYPE html>"));
475 assert!(result.contains("<title>Inline Title</title>"));
476 assert!(result.contains("World"));
478 }
479
480 #[test]
481 fn test_theme_registry_new_and_get() {
482 let registry = ThemeRegistry::new().expect("create registry");
483 let list = registry.list();
485 assert!(!list.is_empty());
486 assert!(!registry.base_css().is_empty());
488 }
489
490 #[test]
491 fn test_theme_registry_get_or_default_known() {
492 let registry = ThemeRegistry::new().expect("create registry");
493 let theme = registry.get_or_default("minimal");
495 assert!(theme.is_ok());
496 }
497
498 #[test]
499 fn test_theme_registry_get_or_default_unknown_falls_back() {
500 let registry = ThemeRegistry::new().expect("create registry");
501 let theme = registry.get_or_default("nonexistent-theme-xyz");
503 assert!(theme.is_ok());
504 }
505
506 #[test]
507 fn test_theme_registry_get_missing_returns_none() {
508 let registry = ThemeRegistry::new().expect("create registry");
509 assert!(registry.get("nonexistent-theme-xyz").is_none());
510 }
511
512 #[test]
513 fn test_resolve_theme_no_override() {
514 use std::collections::HashMap;
515 use std::path::PathBuf;
516 use typub_core::{Content, ContentFormat, ContentMeta};
517
518 let fallback_theme = Theme {
519 id: "fallback".to_string(),
520 name: "Fallback".to_string(),
521 css: "body {}".to_string(),
522 };
523
524 let content = Content {
525 path: PathBuf::from("/tmp/test-post"),
526 meta: ContentMeta {
527 title: "Test".to_string(),
528 created: chrono::NaiveDate::from_ymd_opt(2026, 1, 1).expect("valid date"),
529 updated: None,
530 tags: vec![],
531 categories: vec![],
532 published: None,
533 theme: None,
534 internal_link_target: None,
535 preamble: None,
536 platforms: HashMap::new(),
537 },
538 content_file: PathBuf::from("/tmp/test-post/content.typ"),
539 source_format: ContentFormat::Typst,
540 slides_file: None,
541 assets: vec![],
542 };
543
544 let global_config = typub_config::Config::default();
545 let registry = ThemeRegistry::new().expect("create registry");
546
547 let result = resolve_theme(
549 "wechat",
550 &content,
551 &global_config,
552 None,
553 ®istry,
554 &fallback_theme,
555 );
556 assert_eq!(result.id, "fallback");
557 }
558
559 #[test]
560 fn test_resolve_theme_with_platform_override() {
561 use std::collections::HashMap;
562 use std::path::PathBuf;
563 use typub_core::{Content, ContentFormat, ContentMeta, PostPlatformConfig};
564
565 let fallback_theme = Theme {
566 id: "fallback".to_string(),
567 name: "Fallback".to_string(),
568 css: "body {}".to_string(),
569 };
570
571 let mut platforms = HashMap::new();
572 let mut extra = HashMap::new();
573 extra.insert(
575 "theme".to_string(),
576 toml::Value::String("minimal".to_string()),
577 );
578 platforms.insert(
579 "wechat".to_string(),
580 PostPlatformConfig {
581 published: None,
582 internal_link_target: None,
583 extra,
584 },
585 );
586
587 let content = Content {
588 path: PathBuf::from("/tmp/test-post"),
589 meta: ContentMeta {
590 title: "Test".to_string(),
591 created: chrono::NaiveDate::from_ymd_opt(2026, 1, 1).expect("valid date"),
592 updated: None,
593 tags: vec![],
594 categories: vec![],
595 published: None,
596 theme: None,
597 internal_link_target: None,
598 preamble: None,
599 platforms,
600 },
601 content_file: PathBuf::from("/tmp/test-post/content.typ"),
602 source_format: ContentFormat::Typst,
603 slides_file: None,
604 assets: vec![],
605 };
606
607 let global_config = typub_config::Config::default();
608 let registry = ThemeRegistry::new().expect("create registry");
609
610 let result = resolve_theme(
611 "wechat",
612 &content,
613 &global_config,
614 None,
615 ®istry,
616 &fallback_theme,
617 );
618 assert_eq!(result.id, "minimal");
620 }
621
622 #[test]
623 fn test_resolve_theme_layer_5_profile_default() {
624 use std::collections::HashMap;
625 use std::path::PathBuf;
626 use typub_core::{Content, ContentFormat, ContentMeta};
627
628 let fallback_theme = Theme {
629 id: "fallback".to_string(),
630 name: "Fallback".to_string(),
631 css: "body {}".to_string(),
632 };
633
634 let content = Content {
635 path: PathBuf::from("/tmp/test-post"),
636 meta: ContentMeta {
637 title: "Test".to_string(),
638 created: chrono::NaiveDate::from_ymd_opt(2026, 1, 1).expect("valid date"),
639 updated: None,
640 tags: vec![],
641 categories: vec![],
642 published: None,
643 theme: None,
644 internal_link_target: None,
645 preamble: None,
646 platforms: HashMap::new(),
647 },
648 content_file: PathBuf::from("/tmp/test-post/content.typ"),
649 source_format: ContentFormat::Typst,
650 slides_file: None,
651 assets: vec![],
652 };
653
654 let global_config = typub_config::Config::default();
655 let registry = ThemeRegistry::new().expect("create registry");
656
657 let result = resolve_theme(
659 "wechat",
660 &content,
661 &global_config,
662 Some("elegant"),
663 ®istry,
664 &fallback_theme,
665 );
666 assert_eq!(result.id, "elegant");
668 }
669}