1pub mod graph;
12pub mod theme;
13pub mod timeline;
14
15use anyhow::{Context, Result};
16use std::collections::{HashMap, HashSet};
17use std::fs;
18use std::path::Path;
19use tera::Tera;
20
21use crate::config::SiteConfig;
22use crate::spec::{Spec, SpecStatus};
23
24pub mod embedded {
26 pub const BASE_HTML: &str = include_str!("../../templates/site/base.html");
27 pub const INDEX_HTML: &str = include_str!("../../templates/site/index.html");
28 pub const SPEC_HTML: &str = include_str!("../../templates/site/spec.html");
29 pub const STATUS_INDEX_HTML: &str = include_str!("../../templates/site/status-index.html");
30 pub const LABEL_INDEX_HTML: &str = include_str!("../../templates/site/label-index.html");
31 pub const TIMELINE_HTML: &str = include_str!("../../templates/site/timeline.html");
32 pub const GRAPH_HTML: &str = include_str!("../../templates/site/graph.html");
33 pub const CHANGELOG_HTML: &str = include_str!("../../templates/site/changelog.html");
34 pub const STYLES_CSS: &str = include_str!("../../templates/site/styles.css");
35}
36
37#[derive(Debug, Clone, serde::Serialize)]
39pub struct SiteStats {
40 pub total: usize,
41 pub completed: usize,
42 pub in_progress: usize,
43 pub pending: usize,
44 pub failed: usize,
45 pub other: usize,
46}
47
48impl SiteStats {
49 pub fn from_specs(specs: &[&Spec]) -> Self {
50 let mut stats = Self {
51 total: specs.len(),
52 completed: 0,
53 in_progress: 0,
54 pending: 0,
55 failed: 0,
56 other: 0,
57 };
58
59 for spec in specs {
60 match spec.frontmatter.status {
61 SpecStatus::Completed => stats.completed += 1,
62 SpecStatus::InProgress => stats.in_progress += 1,
63 SpecStatus::Pending => stats.pending += 1,
64 SpecStatus::Failed => stats.failed += 1,
65 _ => stats.other += 1,
66 }
67 }
68
69 stats
70 }
71}
72
73#[derive(Debug, Clone, serde::Serialize)]
75pub struct SpecTemplateData {
76 pub id: String,
77 pub short_id: String,
78 pub title: Option<String>,
79 pub status: String,
80 pub r#type: String,
81 pub labels: Vec<String>,
82 pub depends_on: Vec<String>,
83 pub target_files: Vec<String>,
84 pub completed_at: Option<String>,
85 pub model: Option<String>,
86 pub body_html: String,
87}
88
89impl SpecTemplateData {
90 pub fn from_spec(spec: &Spec, redacted_fields: &[String]) -> Self {
91 let short_id = spec
93 .id
94 .split('-')
95 .next_back()
96 .map(|s| s.to_string())
97 .unwrap_or_else(|| spec.id.clone());
98
99 let body_html = markdown_to_html(&spec.body);
101
102 let status = format!("{:?}", spec.frontmatter.status).to_lowercase();
104
105 Self {
106 id: spec.id.clone(),
107 short_id,
108 title: spec.title.clone(),
109 status,
110 r#type: spec.frontmatter.r#type.clone(),
111 labels: spec.frontmatter.labels.clone().unwrap_or_default(),
112 depends_on: if redacted_fields.contains(&"depends_on".to_string()) {
113 vec![]
114 } else {
115 spec.frontmatter.depends_on.clone().unwrap_or_default()
116 },
117 target_files: if redacted_fields.contains(&"target_files".to_string()) {
118 vec![]
119 } else {
120 spec.frontmatter.target_files.clone().unwrap_or_default()
121 },
122 completed_at: if redacted_fields.contains(&"completed_at".to_string()) {
123 None
124 } else {
125 spec.frontmatter.completed_at.clone()
126 },
127 model: if redacted_fields.contains(&"model".to_string()) {
128 None
129 } else {
130 spec.frontmatter.model.clone()
131 },
132 body_html,
133 }
134 }
135}
136
137fn markdown_to_html(markdown: &str) -> String {
139 use pulldown_cmark::{html, Options, Parser};
140
141 let options = Options::all();
142 let parser = Parser::new_ext(markdown, options);
143
144 let mut html_output = String::new();
145 html::push_html(&mut html_output, parser);
146
147 html_output
148}
149
150pub struct SiteGenerator {
152 config: SiteConfig,
153 tera: Tera,
154 specs: Vec<Spec>,
155}
156
157impl SiteGenerator {
158 pub fn new(config: SiteConfig, specs: Vec<Spec>, theme_dir: Option<&Path>) -> Result<Self> {
160 let tera = if let Some(dir) = theme_dir {
161 if dir.exists() {
162 let pattern = format!("{}/**/*.html", dir.display());
164 Tera::new(&pattern)
165 .with_context(|| format!("Failed to load templates from {}", dir.display()))?
166 } else {
167 Self::create_embedded_tera()?
168 }
169 } else {
170 Self::create_embedded_tera()?
171 };
172
173 Ok(Self {
174 config,
175 tera,
176 specs,
177 })
178 }
179
180 fn create_embedded_tera() -> Result<Tera> {
182 let mut tera = Tera::default();
183
184 tera.add_raw_template("base.html", embedded::BASE_HTML)?;
185 tera.add_raw_template("index.html", embedded::INDEX_HTML)?;
186 tera.add_raw_template("spec.html", embedded::SPEC_HTML)?;
187 tera.add_raw_template("status-index.html", embedded::STATUS_INDEX_HTML)?;
188 tera.add_raw_template("label-index.html", embedded::LABEL_INDEX_HTML)?;
189 tera.add_raw_template("timeline.html", embedded::TIMELINE_HTML)?;
190 tera.add_raw_template("graph.html", embedded::GRAPH_HTML)?;
191 tera.add_raw_template("changelog.html", embedded::CHANGELOG_HTML)?;
192
193 tera.register_filter("slugify", slugify_filter);
195
196 Ok(tera)
197 }
198
199 fn filter_specs(&self) -> Vec<&Spec> {
201 self.specs
202 .iter()
203 .filter(|spec| {
204 if let Some(false) = spec.frontmatter.public {
206 return false;
207 }
208
209 let status_str = format!("{:?}", spec.frontmatter.status).to_lowercase();
211 if !self.config.include.statuses.is_empty()
212 && !self.config.include.statuses.contains(&status_str)
213 {
214 return false;
215 }
216
217 if !self.config.include.labels.is_empty() {
219 let spec_labels = spec.frontmatter.labels.as_ref();
220 if let Some(labels) = spec_labels {
221 if !labels
222 .iter()
223 .any(|l| self.config.include.labels.contains(l))
224 {
225 return false;
226 }
227 } else {
228 return false;
229 }
230 }
231
232 if let Some(labels) = &spec.frontmatter.labels {
234 if labels
235 .iter()
236 .any(|l| self.config.exclude.labels.contains(l))
237 {
238 return false;
239 }
240 }
241
242 true
243 })
244 .collect()
245 }
246
247 fn collect_labels(&self, specs: &[&Spec]) -> Vec<String> {
249 let mut labels: HashSet<String> = HashSet::new();
250
251 for spec in specs {
252 if let Some(spec_labels) = &spec.frontmatter.labels {
253 for label in spec_labels {
254 labels.insert(label.clone());
255 }
256 }
257 }
258
259 let mut labels: Vec<_> = labels.into_iter().collect();
260 labels.sort();
261 labels
262 }
263
264 pub fn build(&self, output_dir: &Path) -> Result<BuildResult> {
266 let mut result = BuildResult::default();
267
268 fs::create_dir_all(output_dir)?;
270 fs::create_dir_all(output_dir.join("specs"))?;
271 fs::create_dir_all(output_dir.join("status"))?;
272 fs::create_dir_all(output_dir.join("labels"))?;
273
274 let filtered_specs = self.filter_specs();
276 let labels = self.collect_labels(&filtered_specs);
277 let stats = SiteStats::from_specs(&filtered_specs);
278
279 let mut base_context = tera::Context::new();
281 base_context.insert("site_title", &self.config.title);
282 base_context.insert("base_url", &self.config.base_url);
283 base_context.insert("features", &self.config.features);
284 base_context.insert("labels", &labels);
285
286 let css_content = if let Some(theme_path) = self.find_theme_file("styles.css") {
288 fs::read_to_string(&theme_path)?
289 } else {
290 embedded::STYLES_CSS.to_string()
291 };
292 fs::write(output_dir.join("styles.css"), css_content)?;
293 result.files_written += 1;
294
295 let spec_data: Vec<SpecTemplateData> = filtered_specs
297 .iter()
298 .map(|s| SpecTemplateData::from_spec(s, &self.config.exclude.fields))
299 .collect();
300
301 for (i, spec) in spec_data.iter().enumerate() {
302 let mut context = base_context.clone();
303 context.insert("spec", spec);
304
305 if i > 0 {
307 context.insert("prev_spec", &spec_data[i - 1]);
308 }
309 if i < spec_data.len() - 1 {
310 context.insert("next_spec", &spec_data[i + 1]);
311 }
312
313 let html = self.tera.render("spec.html", &context)?;
314 let spec_path = output_dir.join("specs").join(format!("{}.html", spec.id));
315 fs::write(&spec_path, html)?;
316 result.files_written += 1;
317 }
318
319 {
321 let mut context = base_context.clone();
322 context.insert("specs", &spec_data);
323 context.insert("stats", &stats);
324
325 let html = self.tera.render("index.html", &context)?;
326 fs::write(output_dir.join("index.html"), html)?;
327 result.files_written += 1;
328 }
329
330 if self.config.features.status_indexes {
332 for (status, display) in [
333 ("completed", "Completed"),
334 ("in_progress", "In Progress"),
335 ("pending", "Pending"),
336 ] {
337 let status_specs: Vec<_> = spec_data
338 .iter()
339 .filter(|s| s.status == status)
340 .cloned()
341 .collect();
342
343 let mut context = base_context.clone();
344 context.insert("status", status);
345 context.insert("status_display", display);
346 context.insert("specs", &status_specs);
347
348 let html = self.tera.render("status-index.html", &context)?;
349 let filename = format!("{}.html", status.replace('_', "-"));
350 fs::write(output_dir.join("status").join(&filename), html)?;
351 result.files_written += 1;
352 }
353 }
354
355 if self.config.features.label_indexes {
357 for label in &labels {
358 let label_specs: Vec<_> = spec_data
359 .iter()
360 .filter(|s| s.labels.contains(label))
361 .cloned()
362 .collect();
363
364 let mut context = base_context.clone();
365 context.insert("label", label);
366 context.insert("specs", &label_specs);
367
368 let html = self.tera.render("label-index.html", &context)?;
369 let filename = format!("{}.html", slugify(label));
370 fs::write(output_dir.join("labels").join(&filename), html)?;
371 result.files_written += 1;
372 }
373 }
374
375 if self.config.features.timeline {
377 let timeline_groups = timeline::build_timeline_groups(
378 &filtered_specs,
379 self.config.timeline.group_by,
380 self.config.timeline.include_pending,
381 );
382
383 let mut context = base_context.clone();
384 context.insert("timeline_groups", &timeline_groups);
385
386 let html = self.tera.render("timeline.html", &context)?;
387 fs::write(output_dir.join("timeline.html"), html)?;
388 result.files_written += 1;
389 }
390
391 if self.config.features.dependency_graph {
393 let (ascii_graph, roots, leaves) =
394 graph::build_dependency_graph(&filtered_specs, self.config.graph.detail);
395
396 let roots_data: Vec<_> = roots
397 .iter()
398 .map(|id| {
399 spec_data
400 .iter()
401 .find(|s| &s.id == id)
402 .cloned()
403 .unwrap_or_else(|| SpecTemplateData {
404 id: id.clone(),
405 short_id: id.clone(),
406 title: None,
407 status: "unknown".to_string(),
408 r#type: "unknown".to_string(),
409 labels: vec![],
410 depends_on: vec![],
411 target_files: vec![],
412 completed_at: None,
413 model: None,
414 body_html: String::new(),
415 })
416 })
417 .collect();
418
419 let leaves_data: Vec<_> = leaves
420 .iter()
421 .map(|id| {
422 spec_data
423 .iter()
424 .find(|s| &s.id == id)
425 .cloned()
426 .unwrap_or_else(|| SpecTemplateData {
427 id: id.clone(),
428 short_id: id.clone(),
429 title: None,
430 status: "unknown".to_string(),
431 r#type: "unknown".to_string(),
432 labels: vec![],
433 depends_on: vec![],
434 target_files: vec![],
435 completed_at: None,
436 model: None,
437 body_html: String::new(),
438 })
439 })
440 .collect();
441
442 let mut context = base_context.clone();
443 context.insert("ascii_graph", &ascii_graph);
444 context.insert("roots", &roots_data);
445 context.insert("leaves", &leaves_data);
446
447 let html = self.tera.render("graph.html", &context)?;
448 fs::write(output_dir.join("graph.html"), html)?;
449 result.files_written += 1;
450 }
451
452 if self.config.features.changelog {
454 let changelog_groups = build_changelog_groups(&filtered_specs);
455
456 let changelog_data: Vec<HashMap<String, serde_json::Value>> = changelog_groups
457 .into_iter()
458 .map(|(date, specs)| {
459 let mut map = HashMap::new();
460 map.insert("date".to_string(), serde_json::json!(date));
461 let specs_data: Vec<_> = specs
462 .iter()
463 .map(|s| SpecTemplateData::from_spec(s, &self.config.exclude.fields))
464 .collect();
465 map.insert("specs".to_string(), serde_json::json!(specs_data));
466 map
467 })
468 .collect();
469
470 let mut context = base_context.clone();
471 context.insert("changelog_groups", &changelog_data);
472
473 let html = self.tera.render("changelog.html", &context)?;
474 fs::write(output_dir.join("changelog.html"), html)?;
475 result.files_written += 1;
476 }
477
478 result.specs_included = filtered_specs.len();
479 Ok(result)
480 }
481
482 fn find_theme_file(&self, filename: &str) -> Option<std::path::PathBuf> {
484 let theme_dir = Path::new(".chant/site/theme");
485 if theme_dir.exists() {
486 let path = theme_dir.join(filename);
487 if path.exists() {
488 return Some(path);
489 }
490 }
491 None
492 }
493}
494
495fn build_changelog_groups<'a>(specs: &[&'a Spec]) -> Vec<(String, Vec<&'a Spec>)> {
497 let mut groups: HashMap<String, Vec<&'a Spec>> = HashMap::new();
498
499 for spec in specs {
500 if spec.frontmatter.status == SpecStatus::Completed {
501 let date = if let Some(completed_at) = &spec.frontmatter.completed_at {
503 completed_at
504 .split('T')
505 .next()
506 .unwrap_or("Unknown")
507 .to_string()
508 } else {
509 let parts: Vec<_> = spec.id.split('-').collect();
511 if parts.len() >= 3 {
512 format!("{}-{}-{}", parts[0], parts[1], parts[2])
513 } else {
514 "Unknown".to_string()
515 }
516 };
517
518 groups.entry(date).or_default().push(*spec);
519 }
520 }
521
522 let mut sorted: Vec<_> = groups.into_iter().collect();
524 sorted.sort_by(|a, b| b.0.cmp(&a.0));
525 sorted
526}
527
528pub fn slugify(s: &str) -> String {
530 s.to_lowercase()
531 .chars()
532 .map(|c| if c.is_alphanumeric() { c } else { '-' })
533 .collect::<String>()
534 .split('-')
535 .filter(|s| !s.is_empty())
536 .collect::<Vec<_>>()
537 .join("-")
538}
539
540fn slugify_filter(
542 value: &tera::Value,
543 _args: &HashMap<String, tera::Value>,
544) -> tera::Result<tera::Value> {
545 match value.as_str() {
546 Some(s) => Ok(tera::Value::String(slugify(s))),
547 None => Ok(value.clone()),
548 }
549}
550
551#[derive(Debug, Default)]
553pub struct BuildResult {
554 pub files_written: usize,
555 pub specs_included: usize,
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561
562 #[test]
563 fn test_slugify() {
564 assert_eq!(slugify("Hello World"), "hello-world");
565 assert_eq!(slugify("API_Integration"), "api-integration");
566 assert_eq!(slugify("feature/auth"), "feature-auth");
567 assert_eq!(slugify("multiple spaces"), "multiple-spaces");
568 }
569
570 #[test]
571 fn test_markdown_to_html() {
572 let md = "# Hello\n\nThis is **bold** text.";
573 let html = markdown_to_html(md);
574 assert!(html.contains("<h1>Hello</h1>"));
575 assert!(html.contains("<strong>bold</strong>"));
576 }
577}