git_adr/export/
html.rs

1//! HTML export functionality.
2
3use crate::core::Adr;
4use crate::export::{ExportResult, Exporter};
5use crate::Error;
6use std::path::Path;
7
8/// Escape HTML special characters to prevent XSS attacks.
9fn html_escape(s: &str) -> String {
10    s.chars()
11        .map(|c| match c {
12            '&' => "&".to_string(),
13            '<' => "&lt;".to_string(),
14            '>' => "&gt;".to_string(),
15            '"' => "&quot;".to_string(),
16            '\'' => "&#x27;".to_string(),
17            _ => c.to_string(),
18        })
19        .collect()
20}
21
22/// HTML exporter.
23#[derive(Debug, Default)]
24pub struct HtmlExporter {
25    /// Include CSS styling.
26    pub include_style: bool,
27    /// Generate index page.
28    pub generate_index: bool,
29}
30
31impl HtmlExporter {
32    /// Create a new HTML exporter.
33    #[must_use]
34    pub fn new() -> Self {
35        Self {
36            include_style: true,
37            generate_index: true,
38        }
39    }
40
41    /// Disable inline CSS styling.
42    #[must_use]
43    pub fn without_style(mut self) -> Self {
44        self.include_style = false;
45        self
46    }
47
48    /// Disable index page generation.
49    #[must_use]
50    pub fn without_index(mut self) -> Self {
51        self.generate_index = false;
52        self
53    }
54
55    /// Generate HTML from markdown content.
56    fn markdown_to_html(&self, content: &str) -> String {
57        // Simple markdown to HTML conversion
58        // TODO: Use a proper markdown parser
59        content
60            .lines()
61            .map(|line| {
62                if let Some(rest) = line.strip_prefix("# ") {
63                    format!("<h1>{}</h1>", html_escape(rest))
64                } else if let Some(rest) = line.strip_prefix("## ") {
65                    format!("<h2>{}</h2>", html_escape(rest))
66                } else if let Some(rest) = line.strip_prefix("### ") {
67                    format!("<h3>{}</h3>", html_escape(rest))
68                } else if let Some(rest) = line.strip_prefix("- ") {
69                    format!("<li>{}</li>", html_escape(rest))
70                } else if line.is_empty() {
71                    String::new()
72                } else {
73                    format!("<p>{}</p>", html_escape(line))
74                }
75            })
76            .collect::<Vec<_>>()
77            .join("\n")
78    }
79
80    /// Generate the CSS style.
81    fn css_style(&self) -> &'static str {
82        r#"
83<style>
84body {
85    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
86    max-width: 800px;
87    margin: 0 auto;
88    padding: 2rem;
89    line-height: 1.6;
90}
91h1 { color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 0.5rem; }
92h2 { color: #34495e; margin-top: 2rem; }
93h3 { color: #7f8c8d; }
94.status {
95    display: inline-block;
96    padding: 0.25rem 0.75rem;
97    border-radius: 4px;
98    font-size: 0.875rem;
99    font-weight: 500;
100}
101.status-proposed { background: #f39c12; color: white; }
102.status-accepted { background: #27ae60; color: white; }
103.status-deprecated { background: #95a5a6; color: white; }
104.status-superseded { background: #9b59b6; color: white; }
105.status-rejected { background: #e74c3c; color: white; }
106.tags { margin-top: 1rem; }
107.tag {
108    display: inline-block;
109    padding: 0.2rem 0.5rem;
110    background: #ecf0f1;
111    border-radius: 3px;
112    font-size: 0.8rem;
113    margin-right: 0.5rem;
114}
115</style>
116"#
117    }
118}
119
120impl Exporter for HtmlExporter {
121    fn export(&self, adr: &Adr, path: &Path) -> Result<(), Error> {
122        // Escape all user-controlled content to prevent XSS
123        let escaped_id = html_escape(&adr.id);
124        let escaped_title = html_escape(&adr.frontmatter.title);
125        let escaped_status = html_escape(&adr.frontmatter.status.to_string());
126        let status_class = format!("status-{}", escaped_status);
127        let body_html = self.markdown_to_html(&adr.body);
128
129        let tags_html = if adr.frontmatter.tags.is_empty() {
130            String::new()
131        } else {
132            let tags: Vec<String> = adr
133                .frontmatter
134                .tags
135                .iter()
136                .map(|t| format!("<span class=\"tag\">{}</span>", html_escape(t)))
137                .collect();
138            format!("<div class=\"tags\">{}</div>", tags.join(""))
139        };
140
141        let style = if self.include_style {
142            self.css_style()
143        } else {
144            ""
145        };
146
147        let html = format!(
148            r#"<!DOCTYPE html>
149<html lang="en">
150<head>
151    <meta charset="UTF-8">
152    <meta name="viewport" content="width=device-width, initial-scale=1.0">
153    <title>{escaped_id} - {escaped_title}</title>
154    {style}
155</head>
156<body>
157    <article>
158        <header>
159            <h1>{escaped_title}</h1>
160            <span class="status {status_class}">{escaped_status}</span>
161            {tags_html}
162        </header>
163        <main>
164            {body_html}
165        </main>
166    </article>
167</body>
168</html>"#
169        );
170
171        std::fs::write(path, html).map_err(|e| Error::IoError {
172            message: format!("Failed to write {}: {e}", path.display()),
173        })
174    }
175
176    fn export_all(&self, adrs: &[Adr], dir: &Path) -> Result<ExportResult, Error> {
177        std::fs::create_dir_all(dir).map_err(|e| Error::IoError {
178            message: format!("Failed to create directory {}: {e}", dir.display()),
179        })?;
180
181        let mut result = ExportResult::default();
182
183        for adr in adrs {
184            let path = dir.join(format!("{}.html", adr.id));
185            match self.export(adr, &path) {
186                Ok(()) => {
187                    result.exported += 1;
188                    result.files.push(path.display().to_string());
189                },
190                Err(e) => {
191                    result.errors.push(format!("{}: {e}", adr.id));
192                },
193            }
194        }
195
196        // Generate index if requested
197        if self.generate_index && !adrs.is_empty() {
198            let index_path = dir.join("index.html");
199            if let Err(e) = self.generate_index_page(adrs, &index_path) {
200                result.errors.push(format!("index: {e}"));
201            } else {
202                result.files.push(index_path.display().to_string());
203            }
204        }
205
206        Ok(result)
207    }
208}
209
210impl HtmlExporter {
211    /// Generate an index page linking to all ADRs.
212    fn generate_index_page(&self, adrs: &[Adr], path: &Path) -> Result<(), Error> {
213        let style = if self.include_style {
214            self.css_style()
215        } else {
216            ""
217        };
218
219        let rows: Vec<String> = adrs
220            .iter()
221            .map(|adr| {
222                // Escape all user-controlled content to prevent XSS
223                let escaped_id = html_escape(&adr.id);
224                let escaped_title = html_escape(&adr.frontmatter.title);
225                let escaped_status = html_escape(&adr.frontmatter.status.to_string());
226                let status_class = format!("status-{}", escaped_status);
227                format!(
228                    r#"<tr>
229                <td><a href="{escaped_id}.html">{escaped_id}</a></td>
230                <td>{escaped_title}</td>
231                <td><span class="status {status_class}">{escaped_status}</span></td>
232            </tr>"#
233                )
234            })
235            .collect();
236
237        let html = format!(
238            r#"<!DOCTYPE html>
239<html lang="en">
240<head>
241    <meta charset="UTF-8">
242    <meta name="viewport" content="width=device-width, initial-scale=1.0">
243    <title>Architecture Decision Records</title>
244    {style}
245    <style>
246        table {{ width: 100%; border-collapse: collapse; }}
247        th, td {{ padding: 0.75rem; text-align: left; border-bottom: 1px solid #ecf0f1; }}
248        th {{ background: #f8f9fa; font-weight: 600; }}
249        tr:hover {{ background: #f8f9fa; }}
250        a {{ color: #3498db; text-decoration: none; }}
251        a:hover {{ text-decoration: underline; }}
252    </style>
253</head>
254<body>
255    <h1>Architecture Decision Records</h1>
256    <table>
257        <thead>
258            <tr>
259                <th>ID</th>
260                <th>Title</th>
261                <th>Status</th>
262            </tr>
263        </thead>
264        <tbody>
265            {}
266        </tbody>
267    </table>
268</body>
269</html>"#,
270            rows.join("\n")
271        );
272
273        std::fs::write(path, html).map_err(|e| Error::IoError {
274            message: format!("Failed to write {}: {e}", path.display()),
275        })
276    }
277}