Skip to main content

typub_theme/
lib.rs

1//! Theme system for HTML output styling
2//!
3//! Provides a unified theming system for all HTML-outputting platforms.
4//! Themes are CSS files that can be applied with or without inlining.
5//!
6//! Themes are loaded in two layers:
7//! 1. **Builtins** - Embedded at compile time from `templates/themes/`
8//! 2. **User themes** - Loaded at runtime from user's `templates/themes/` directory
9//!
10//! User themes with the same ID override builtins; new IDs extend the registry.
11
12mod builtin_themes;
13
14use anyhow::{Context, Result};
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17
18// Re-export builtin CSS for external use
19pub use builtin_themes::{BUILTIN_BASE_CSS, BUILTIN_PREVIEW_CSS, BUILTIN_THEMES};
20
21/// A theme with its CSS content
22#[derive(Debug, Clone)]
23pub struct Theme {
24    /// Theme identifier (filename without extension)
25    pub id: String,
26    /// Human-readable name
27    pub name: String,
28    /// Combined CSS (base + theme)
29    pub css: String,
30}
31
32/// Registry of available themes
33pub struct ThemeRegistry {
34    themes: HashMap<String, Theme>,
35    base_css: String,
36}
37
38impl ThemeRegistry {
39    /// Create a new registry with embedded builtins, overlaid with user themes.
40    ///
41    /// 1. Start with builtin themes (embedded at compile time)
42    /// 2. Overlay user themes from `templates/themes/` if directory exists
43    /// 3. User themes with same ID override builtins; new IDs are added
44    pub fn new() -> Result<Self> {
45        // Start with builtins
46        let mut registry = Self::from_builtins();
47
48        // Overlay user themes if directory exists
49        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    /// Create registry from compile-time embedded builtins only.
59    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        // Ensure "minimal" theme exists (base CSS only)
77        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    /// Get the user themes directory path (project-local).
92    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    /// Load user themes from a directory, overlaying/extending existing themes.
98    fn load_user_themes(&mut self, dir: &Path) -> Result<()> {
99        // Check for user _base.css override
100        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        // Load user theme CSS files
108        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                // Skip underscore-prefixed files (_base.css, _preview-*.css)
119                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                // Combine base + theme CSS
127                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                // Insert or override existing theme
134                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    /// Convert theme ID to display name
149    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    /// Get a theme by ID
170    pub fn get(&self, id: &str) -> Option<&Theme> {
171        self.themes.get(id)
172    }
173
174    /// Get a theme by ID, falling back to minimal
175    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    /// List all available theme IDs
184    pub fn list(&self) -> Vec<&str> {
185        self.themes.keys().map(|s| s.as_str()).collect()
186    }
187
188    /// Get the base CSS
189    pub fn base_css(&self) -> &str {
190        &self.base_css
191    }
192
193    /// Load preview CSS for a specific adapter/platform.
194    ///
195    /// Preview CSS files are named `_preview-{platform}.css` and provide
196    /// styling for the preview page (toolbar, copy button, container, etc.)
197    ///
198    /// Resolution order:
199    /// 1. User's `templates/themes/_preview-{platform}.css` (if exists)
200    /// 2. Builtin preview CSS (embedded at compile time)
201    pub fn load_preview_css(&self, platform: &str) -> Option<String> {
202        // Check user directory first (override)
203        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        // Fall back to builtin
213        builtin_themes::BUILTIN_PREVIEW_CSS
214            .iter()
215            .find(|(id, _)| *id == platform)
216            .map(|(_, css)| css.to_string())
217    }
218}
219
220/// Resolve theme using 5-level resolution chain (similar to RFC-0005's published resolution):
221///
222/// 1. `meta.toml[platforms.X].theme` — per-content platform-specific
223/// 2. `meta.toml.theme` — per-content default
224/// 3. `typub.toml[platforms.X].theme` — global platform-specific
225/// 4. `typub.toml.theme` — global default
226/// 5. Profile `default_theme` — hardcoded in profiles.toml
227///
228/// Falls back to the provided `fallback` theme if no match at any layer.
229pub 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    // Layer 1: meta.toml[platforms.X].theme
238    let theme_id: Option<String> = content
239        .platform_config(platform_id)
240        .and_then(|c| c.get_str("theme"))
241        // Layer 2: meta.toml.theme
242        .or_else(|| content.meta.theme.as_deref().map(String::from))
243        // Layer 3: typub.toml[platforms.X].theme
244        .or_else(|| {
245            global_config
246                .platforms
247                .get(platform_id)
248                .and_then(|p| p.theme.as_deref().map(String::from))
249        })
250        // Layer 4: typub.toml.theme
251        .or_else(|| global_config.theme.as_deref().map(String::from))
252        // Layer 5: profile default_theme
253        .or_else(|| profile_default_theme.map(|s| s.to_string()));
254
255    // Resolve theme ID to actual theme
256    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
265/// Load a theme by ID from the registry, with fallback.
266///
267/// This is the second half of theme resolution - after the theme ID has been
268/// resolved via `ResolvedConfig`, this function loads the actual Theme object.
269///
270/// # Arguments
271///
272/// * `theme_id` - Pre-resolved theme ID (from `ResolvedConfig.theme_id`)
273/// * `profile_default` - Adapter-specific default theme ID (layer 5)
274/// * `registry` - Theme registry to look up themes
275/// * `fallback` - Fallback theme if no match found
276pub fn load_theme(
277    theme_id: Option<&str>,
278    profile_default: Option<&str>,
279    registry: &ThemeRegistry,
280    fallback: &Theme,
281) -> Theme {
282    // Try resolved theme_id first (layers 1-4)
283    if let Some(id) = theme_id
284        && let Some(theme) = registry.get(id)
285    {
286        return theme.clone();
287    }
288
289    // Try profile default (layer 5)
290    if let Some(id) = profile_default
291        && let Some(theme) = registry.get(id)
292    {
293        return theme.clone();
294    }
295
296    // Final fallback
297    fallback.clone()
298}
299
300/// Apply theme to HTML body content.
301///
302/// * `html` - The HTML content (body only, no DOCTYPE)
303/// * `theme` - The theme to apply
304/// * `inline` - If true, inline all CSS into style attributes (for WeChat/copy-paste)
305///
306/// Returns themed HTML with CSS either inlined or as `<style>` block.
307///
308/// Note: No wrapper div is added. Theme CSS uses direct element selectors
309/// (e.g., `h1`, `p`) rather than `.content h1` to ensure styles are inlined
310/// directly onto elements. This is required for platforms like WeChat that
311/// filter out `<div>` tags.
312pub fn apply_theme(html: &str, theme: &Theme, inline: bool) -> Result<String> {
313    if inline {
314        // Inline CSS for platforms that don't support <style> tags
315        inline_css(html, &theme.css)
316    } else {
317        // Add CSS as <style> block
318        Ok(format!("<style>\n{}\n</style>\n{}", theme.css, html))
319    }
320}
321
322/// Apply theme and wrap in full HTML document (for preview pages).
323///
324/// Unlike `apply_theme`, this produces a complete `<!DOCTYPE html>` document
325/// with CSS in `<head>` only — no duplicate `<style>` in `<body>`.
326///
327/// Note: No wrapper div is added. Theme CSS uses direct element selectors.
328pub 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
360/// Inline CSS into HTML using css-inline crate
361fn inline_css(html: &str, css: &str) -> Result<String> {
362    // Create a full document for css-inline to process
363    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    // Use css-inline to inline styles
377    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    // Extract just the body content
385    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            // No .content wrapper - styles apply directly to elements
415            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        // Should have inlined style
422        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            // No .content wrapper - styles apply directly to elements
432            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        // Should have style block
439        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        // Inline mode: no <style> in head, CSS inlined into elements
477        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        // Should always have at least one theme
484        let list = registry.list();
485        assert!(!list.is_empty());
486        // base_css should not be empty
487        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        // "elegant" or "minimal" should exist
494        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        // Unknown theme should fall back to minimal or first available
502        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        // No overrides at any layer, should return fallback
548        let result = resolve_theme(
549            "wechat",
550            &content,
551            &global_config,
552            None,
553            &registry,
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        // Use "minimal" since that's always present in the registry (layer 1)
574        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            &registry,
616            &fallback_theme,
617        );
618        // Should resolve to "minimal" from layer 1
619        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        // Layer 5: profile default_theme = "elegant"
658        let result = resolve_theme(
659            "wechat",
660            &content,
661            &global_config,
662            Some("elegant"),
663            &registry,
664            &fallback_theme,
665        );
666        // Should resolve to "elegant" from layer 5
667        assert_eq!(result.id, "elegant");
668    }
669}