adrscope/infrastructure/renderer/
html.rs

1//! HTML viewer generation using askama templates.
2
3use askama::Template;
4use serde::Serialize;
5use time::OffsetDateTime;
6
7use crate::domain::{Adr, Facets, Graph};
8use crate::error::{Error, Result};
9
10/// Theme for the HTML viewer.
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
12pub enum Theme {
13    /// Light theme.
14    Light,
15    /// Dark theme.
16    Dark,
17    /// Auto (follows system preference).
18    #[default]
19    Auto,
20}
21
22impl Theme {
23    /// Returns the theme as a string for use in templates.
24    #[must_use]
25    pub const fn as_str(&self) -> &'static str {
26        match self {
27            Self::Light => "light",
28            Self::Dark => "dark",
29            Self::Auto => "auto",
30        }
31    }
32}
33
34impl std::str::FromStr for Theme {
35    type Err = String;
36
37    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
38        match s.to_lowercase().as_str() {
39            "light" => Ok(Self::Light),
40            "dark" => Ok(Self::Dark),
41            "auto" => Ok(Self::Auto),
42            _ => Err(format!("invalid theme: {s}")),
43        }
44    }
45}
46
47/// Configuration for HTML rendering.
48#[derive(Debug, Clone, Default)]
49pub struct RenderConfig {
50    /// Page title.
51    pub title: String,
52    /// Theme preference.
53    pub theme: Theme,
54    /// Whether to embed all assets inline.
55    pub embed_assets: bool,
56}
57
58impl RenderConfig {
59    /// Creates a new render configuration with the given title.
60    #[must_use]
61    pub fn new(title: impl Into<String>) -> Self {
62        Self {
63            title: title.into(),
64            theme: Theme::default(),
65            embed_assets: true,
66        }
67    }
68
69    /// Sets the theme.
70    #[must_use]
71    pub const fn with_theme(mut self, theme: Theme) -> Self {
72        self.theme = theme;
73        self
74    }
75}
76
77/// Data structure embedded in the HTML for JavaScript consumption.
78#[derive(Debug, Clone, Serialize)]
79pub struct ViewerData {
80    /// Metadata about the generation.
81    pub meta: ViewerMeta,
82    /// All parsed ADRs.
83    pub records: Vec<Adr>,
84    /// Faceted filter data.
85    pub facets: Facets,
86    /// Relationship graph.
87    pub graph: Graph,
88}
89
90/// Metadata embedded in the viewer.
91#[derive(Debug, Clone, Serialize)]
92pub struct ViewerMeta {
93    /// When the viewer was generated.
94    pub generated: String,
95    /// Generator name and version.
96    pub generator: String,
97    /// Schema version.
98    pub schema_version: String,
99    /// Source directory.
100    pub source_dir: String,
101}
102
103impl ViewerMeta {
104    /// Creates metadata for the current generation.
105    #[must_use]
106    pub fn new(source_dir: impl Into<String>) -> Self {
107        Self {
108            generated: OffsetDateTime::now_utc()
109                .format(&time::format_description::well_known::Rfc3339)
110                .unwrap_or_else(|_| "unknown".to_string()),
111            generator: format!("adrscope/{}", env!("CARGO_PKG_VERSION")),
112            schema_version: "1.0.0".to_string(),
113            source_dir: source_dir.into(),
114        }
115    }
116}
117
118/// The main HTML viewer template.
119#[derive(Template)]
120#[template(path = "viewer.html", escape = "none")]
121pub struct ViewerTemplate<'a> {
122    /// Page title.
123    pub title: &'a str,
124    /// Theme preference.
125    pub theme: &'a str,
126    /// Serialized JSON data for embedding.
127    pub data_json: &'a str,
128    /// Embedded CSS.
129    pub css: &'a str,
130    /// Embedded JavaScript.
131    pub js: &'a str,
132}
133
134/// HTML renderer for generating self-contained viewers.
135#[derive(Debug, Clone, Default)]
136pub struct HtmlRenderer;
137
138impl HtmlRenderer {
139    /// Creates a new HTML renderer.
140    #[must_use]
141    pub const fn new() -> Self {
142        Self
143    }
144
145    /// Renders a collection of ADRs to a self-contained HTML viewer.
146    pub fn render(
147        &self,
148        adrs: Vec<Adr>,
149        source_dir: &str,
150        config: &RenderConfig,
151    ) -> Result<String> {
152        // Build the embedded data
153        let data = ViewerData {
154            meta: ViewerMeta::new(source_dir),
155            facets: Facets::from_adrs(&adrs),
156            graph: Graph::from_adrs(&adrs),
157            records: adrs,
158        };
159
160        // Serialize to JSON
161        let data_json =
162            serde_json::to_string(&data).map_err(|e| Error::JsonSerialize(e.to_string()))?;
163
164        // Render the template
165        let template = ViewerTemplate {
166            title: &config.title,
167            theme: config.theme.as_str(),
168            data_json: &data_json,
169            css: include_str!("../../../templates/styles.css"),
170            js: include_str!("../../../templates/app.js"),
171        };
172
173        template.render().map_err(Error::from)
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_theme_from_str() {
183        assert_eq!("light".parse::<Theme>().ok(), Some(Theme::Light));
184        assert_eq!("DARK".parse::<Theme>().ok(), Some(Theme::Dark));
185        assert_eq!("Auto".parse::<Theme>().ok(), Some(Theme::Auto));
186        assert!("invalid".parse::<Theme>().is_err());
187    }
188
189    #[test]
190    fn test_theme_as_str() {
191        assert_eq!(Theme::Light.as_str(), "light");
192        assert_eq!(Theme::Dark.as_str(), "dark");
193        assert_eq!(Theme::Auto.as_str(), "auto");
194    }
195
196    #[test]
197    fn test_render_config_builder() {
198        let config = RenderConfig::new("My ADRs").with_theme(Theme::Dark);
199
200        assert_eq!(config.title, "My ADRs");
201        assert_eq!(config.theme, Theme::Dark);
202    }
203
204    #[test]
205    fn test_viewer_meta_creation() {
206        let meta = ViewerMeta::new("docs/decisions");
207
208        assert!(meta.generated.contains("T")); // ISO 8601 format
209        assert!(meta.generator.starts_with("adrscope/"));
210        assert_eq!(meta.schema_version, "1.0.0");
211        assert_eq!(meta.source_dir, "docs/decisions");
212    }
213}