1use crate::core::Adr;
4use crate::export::{ExportResult, Exporter};
5use crate::Error;
6use std::path::Path;
7
8fn html_escape(s: &str) -> String {
10 s.chars()
11 .map(|c| match c {
12 '&' => "&".to_string(),
13 '<' => "<".to_string(),
14 '>' => ">".to_string(),
15 '"' => """.to_string(),
16 '\'' => "'".to_string(),
17 _ => c.to_string(),
18 })
19 .collect()
20}
21
22#[derive(Debug, Default)]
24pub struct HtmlExporter {
25 pub include_style: bool,
27 pub generate_index: bool,
29}
30
31impl HtmlExporter {
32 #[must_use]
34 pub fn new() -> Self {
35 Self {
36 include_style: true,
37 generate_index: true,
38 }
39 }
40
41 #[must_use]
43 pub fn without_style(mut self) -> Self {
44 self.include_style = false;
45 self
46 }
47
48 #[must_use]
50 pub fn without_index(mut self) -> Self {
51 self.generate_index = false;
52 self
53 }
54
55 fn markdown_to_html(&self, content: &str) -> String {
57 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 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 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 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 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 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}