Skip to main content

chant/site/
mod.rs

1//! Static site generation for chant specs.
2//!
3//! This module provides functionality to generate a static HTML documentation
4//! site from chant specs, including:
5//! - Individual spec pages
6//! - Index pages (by status, by label)
7//! - Timeline visualization
8//! - Dependency graph visualization
9//! - Changelog
10
11pub 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
24/// Embedded default theme templates
25pub 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/// Site statistics
38#[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/// Spec data for templates
74#[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        // Extract short ID (last part after date)
92        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        // Convert body to HTML
100        let body_html = markdown_to_html(&spec.body);
101
102        // Get status as string
103        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
137/// Convert markdown to HTML using pulldown-cmark
138fn 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
150/// Site generator
151pub struct SiteGenerator {
152    config: SiteConfig,
153    tera: Tera,
154    specs: Vec<Spec>,
155}
156
157impl SiteGenerator {
158    /// Create a new site generator with the given configuration
159    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                // Load templates from theme directory
163                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    /// Create a Tera instance with embedded templates
181    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        // Register custom filter for slugify
194        tera.register_filter("slugify", slugify_filter);
195
196        Ok(tera)
197    }
198
199    /// Filter specs based on configuration
200    fn filter_specs(&self) -> Vec<&Spec> {
201        self.specs
202            .iter()
203            .filter(|spec| {
204                // Check public flag
205                if let Some(false) = spec.frontmatter.public {
206                    return false;
207                }
208
209                // Check status filter
210                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                // Check include labels (empty = include all)
218                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                // Check exclude labels
233                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    /// Collect all unique labels from filtered specs
248    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    /// Build the static site
265    pub fn build(&self, output_dir: &Path) -> Result<BuildResult> {
266        let mut result = BuildResult::default();
267
268        // Create output directory structure
269        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        // Filter specs
275        let filtered_specs = self.filter_specs();
276        let labels = self.collect_labels(&filtered_specs);
277        let stats = SiteStats::from_specs(&filtered_specs);
278
279        // Prepare common context
280        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        // Write CSS
287        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        // Generate individual spec pages
296        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            // Add prev/next navigation
306            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        // Generate index page
320        {
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        // Generate status index pages
331        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        // Generate label index pages
356        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        // Generate timeline page
376        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        // Generate dependency graph page
392        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        // Generate changelog page
453        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    /// Find a theme file in the custom theme directory
483    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
495/// Build changelog groups from completed specs
496fn 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            // Get date from completed_at or extract from ID
502            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                // Extract date from ID (e.g., 2026-01-30-00a-xyz -> 2026-01-30)
510                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    // Sort by date descending
523    let mut sorted: Vec<_> = groups.into_iter().collect();
524    sorted.sort_by(|a, b| b.0.cmp(&a.0));
525    sorted
526}
527
528/// Slugify a string for use in URLs
529pub 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
540/// Tera filter for slugify
541fn 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/// Result of building the site
552#[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}