Skip to main content

reflex/pulse/
site.rs

1//! Static site generator
2//!
3//! Orchestrates wiki, changelog, and map into a Zola project.
4//! Generates markdown content with TOML front matter, Tera templates,
5//! and a Zola config. Optionally runs `zola build` to produce HTML.
6
7use anyhow::{Context, Result};
8use rusqlite::Connection;
9use serde::Serialize;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13use super::changelog;
14use super::diff;
15use super::explorer;
16use super::git_intel;
17use super::glossary;
18use super::map::{self, MapFormat, MapZoom};
19use super::narrate;
20use super::onboard;
21use super::pagefind;
22use super::snapshot;
23use super::wiki;
24use super::zola;
25use crate::cache::CacheManager;
26use crate::semantic::providers::LlmProvider;
27
28/// Truncate a string to at most `max_chars` Unicode characters, appending "..." if truncated.
29fn truncate_str(s: &str, max_chars: usize) -> String {
30    let mut chars = s.chars();
31    let truncated: String = chars.by_ref().take(max_chars).collect();
32    if chars.next().is_some() {
33        format!("{}...", truncated)
34    } else {
35        truncated
36    }
37}
38
39/// Site generation configuration
40#[derive(Debug, Clone)]
41pub struct SiteConfig {
42    pub output_dir: PathBuf,
43    pub base_url: String,
44    pub title: String,
45    pub surfaces: Vec<Surface>,
46    pub no_llm: bool,
47    pub clean: bool,
48    pub force_renarrate: bool,
49    /// Maximum concurrent LLM requests (0 = unlimited)
50    pub concurrency: usize,
51    /// Maximum directory depth for module discovery (1=top-level only, 2=default)
52    pub max_depth: u8,
53    /// Minimum file count for a module to be included
54    pub min_files: usize,
55}
56
57#[derive(Debug, Clone, PartialEq)]
58pub enum Surface {
59    Wiki,
60    Changelog,
61    Map,
62    Onboard,
63    Timeline,
64    Glossary,
65    Explorer,
66}
67
68impl Default for SiteConfig {
69    fn default() -> Self {
70        Self {
71            output_dir: PathBuf::from("pulse-site"),
72            base_url: "/".to_string(),
73            title: "Documentation".to_string(),
74            surfaces: vec![
75                Surface::Wiki,
76                Surface::Changelog,
77                Surface::Map,
78                Surface::Onboard,
79                Surface::Timeline,
80                Surface::Glossary,
81                Surface::Explorer,
82            ],
83            no_llm: true,
84            clean: false,
85            force_renarrate: false,
86            concurrency: 0,
87            max_depth: 2,
88            min_files: 1,
89        }
90    }
91}
92
93/// Report from site generation
94#[derive(Debug, Clone, Serialize)]
95pub struct SiteReport {
96    pub output_dir: String,
97    pub pages_generated: usize,
98    pub changelog_generated: bool,
99    pub map_generated: bool,
100    pub onboard_generated: bool,
101    pub timeline_generated: bool,
102    pub glossary_generated: bool,
103    pub explorer_generated: bool,
104    pub narration_mode: String,
105    pub build_success: bool,
106}
107
108/// Generate the complete Zola project and optionally build it.
109///
110/// Uses a 3-phase architecture for maximum parallelism:
111/// 1. **Structural phase** (rayon): Build all structural data concurrently
112/// 2. **Narration phase** (tokio): Fire all LLM calls concurrently
113/// 3. **Write phase**: Assemble and write output files, run zola build
114pub fn generate_site(cache: &CacheManager, config: &SiteConfig) -> Result<SiteReport> {
115    let overall_start = std::time::Instant::now();
116
117    // Auto-snapshot if index has changed since last snapshot
118    let pulse_config = super::config::load_pulse_config(cache.path())?;
119    let ensure_result = snapshot::ensure_snapshot(cache, &pulse_config.retention)?;
120    match &ensure_result {
121        snapshot::EnsureSnapshotResult::Created(info) => {
122            eprintln!(
123                "Auto-snapshot created: {} ({} files)",
124                info.id, info.file_count
125            );
126        }
127        snapshot::EnsureSnapshotResult::Reused(info) => {
128            eprintln!("Using snapshot: {} (index unchanged)", info.id);
129        }
130    }
131
132    // Clean output dir if requested
133    if config.clean && config.output_dir.exists() {
134        std::fs::remove_dir_all(&config.output_dir).context("Failed to clean output directory")?;
135    }
136
137    // Create Zola project structure
138    create_directory_structure(&config.output_dir)?;
139
140    // Write Zola config
141    write_zola_config(
142        &config.output_dir,
143        &config.base_url,
144        &config.title,
145        &config.surfaces,
146    )?;
147
148    // Write templates
149    write_templates(&config.output_dir)?;
150
151    // Write static assets
152    write_static_assets(&config.output_dir)?;
153
154    // Get snapshots for diff
155    let snapshots = snapshot::list_snapshots(cache)?;
156    let current_snapshot = snapshots.first();
157    let baseline_snapshot = snapshots.get(1);
158
159    let snapshot_diff = match (current_snapshot, baseline_snapshot) {
160        (Some(current), Some(baseline)) => {
161            let pulse_config = super::config::load_pulse_config(cache.path())?;
162            diff::compute_diff(&baseline.path, &current.path, &pulse_config.thresholds).ok()
163        }
164        _ => None,
165    };
166
167    // Clear LLM cache if force-renarrate is set
168    if config.force_renarrate && !config.no_llm {
169        let llm_cache = super::llm_cache::LlmCache::new(cache.path());
170        if let Err(e) = llm_cache.clear() {
171            log::warn!("Failed to clear LLM cache: {}", e);
172        }
173    }
174
175    // Create LLM provider (as Arc for concurrent sharing)
176    let provider: Option<Arc<dyn LlmProvider>> = if !config.no_llm {
177        match narrate::create_pulse_provider() {
178            Ok(p) => {
179                eprintln!("LLM provider ready, narration enabled.");
180                Some(Arc::from(p))
181            }
182            Err(e) => {
183                eprintln!("LLM narration unavailable: {}", e);
184                None
185            }
186        }
187    } else {
188        None
189    };
190
191    let llm_cache = provider
192        .as_ref()
193        .map(|_| super::llm_cache::LlmCache::new(cache.path()));
194
195    let mut pages_generated = 0;
196    let mut changelog_generated = false;
197    let mut map_generated = false;
198    let mut onboard_generated = false;
199    let mut timeline_generated = false;
200    let mut glossary_generated = false;
201    let mut explorer_generated = false;
202    let mut has_narration = false;
203
204    let snapshot_id = snapshots
205        .first()
206        .map(|s| s.id.as_str())
207        .unwrap_or("unknown");
208
209    // ══════════════════════════════════════════════════════════════
210    // Phase 1: Parallel Structural (rayon for wiki, sequential for rest)
211    // ══════════════════════════════════════════════════════════════
212    let structural_start = std::time::Instant::now();
213
214    // Build module discovery config from site config
215    let discovery_config = wiki::ModuleDiscoveryConfig {
216        max_depth: config.max_depth,
217        min_files: config.min_files,
218    };
219
220    // 1a. Wiki: parallel structural build
221    let mut wiki_pages_with_context: Vec<wiki::WikiPageWithContext> = Vec::new();
222    if config.surfaces.contains(&Surface::Wiki) {
223        eprintln!("Building wiki structural data (parallel)...");
224        wiki_pages_with_context =
225            wiki::generate_all_pages_structural(cache, snapshot_diff.as_ref(), &discovery_config)?;
226    }
227
228    // Build wiki_page_index from structural data (needed for architecture/overview contexts)
229    let modules = wiki::detect_modules(cache, &discovery_config)?;
230    let module_map: std::collections::HashMap<&str, &wiki::ModuleDefinition> =
231        modules.iter().map(|m| (m.path.as_str(), m)).collect();
232
233    let mut wiki_page_index: Vec<WikiPageMeta> = Vec::new();
234    for pwc in &wiki_pages_with_context {
235        let page = &pwc.page;
236        let module = module_map.get(page.module_path.as_str());
237        let slug = page.module_path.replace('/', "-");
238
239        let summary_preview = format!("{} files", module.map(|m| m.file_count).unwrap_or(0));
240
241        let tier = module.map(|m| m.tier).unwrap_or(1);
242        let parent_path = if tier == 2 {
243            page.module_path.split('/').next().map(|s| s.to_string())
244        } else {
245            None
246        };
247
248        wiki_page_index.push(WikiPageMeta {
249            title: page.title.clone(),
250            slug,
251            file_count: module.map(|m| m.file_count).unwrap_or(0),
252            total_lines: module.map(|m| m.total_lines).unwrap_or(0),
253            description: summary_preview,
254            tier,
255            parent_path,
256        });
257    }
258
259    // 1b. Changelog: extract recent commits
260    let mut changelog_data: Option<changelog::Changelog> = None;
261    if config.surfaces.contains(&Surface::Changelog) {
262        let workspace_root = cache.path().parent().unwrap_or(Path::new("."));
263        changelog_data = match changelog::extract_changelog(workspace_root, 20) {
264            Ok(cl) => Some(cl),
265            Err(e) => {
266                eprintln!("Warning: changelog failed: {e}");
267                None
268            }
269        };
270    }
271
272    // 1c. Map: generate map content + architecture context
273    let mut map_content: Option<String> = None;
274    let mut layered_content: Option<String> = None;
275    let mut arch_context: Option<String> = None;
276    if config.surfaces.contains(&Surface::Map) {
277        map_content = Some(map::generate_map(
278            cache,
279            &MapZoom::Repo,
280            MapFormat::Mermaid,
281        )?);
282        layered_content = map::generate_layered_map(cache, MapFormat::Mermaid).ok();
283        arch_context = Some(build_architecture_context(cache, &wiki_page_index));
284    }
285
286    // 1d. Onboard: detect entry points + reading order
287    let mut onboard_data: Option<onboard::OnboardData> = None;
288    if config.surfaces.contains(&Surface::Onboard) {
289        eprintln!("Building onboard structural data...");
290        let module_count = wiki_page_index.len();
291        onboard_data = match onboard::generate_onboard_structural(cache, module_count) {
292            Ok(data) => Some(data),
293            Err(e) => {
294                eprintln!("  Warning: onboard generation failed: {e}");
295                None
296            }
297        };
298    }
299
300    // 1e. Timeline: extract git history
301    let mut timeline_data: Option<git_intel::GitIntel> = None;
302    if config.surfaces.contains(&Surface::Timeline) {
303        eprintln!("Extracting git history...");
304        let workspace_root = cache.path().parent().unwrap_or(std::path::Path::new("."));
305        timeline_data = match git_intel::extract_git_intel(workspace_root) {
306            Ok(data) => Some(data),
307            Err(e) => {
308                eprintln!("  Warning: timeline generation failed: {e}");
309                None
310            }
311        };
312    }
313
314    // 1f. Glossary: collect structural evidence for the LLM concept pass.
315    //     The actual concept list is generated in Phase 2 from a single
316    //     narration task; here we just gather module/symbol evidence.
317    let mut glossary_evidence: Option<glossary::GlossaryEvidence> = None;
318    let mut glossary_data: Option<glossary::GlossaryData> = None;
319    if config.surfaces.contains(&Surface::Glossary) {
320        eprintln!("Building glossary evidence...");
321        glossary_evidence = match glossary::collect_glossary_evidence(cache) {
322            Ok(data) => data,
323            Err(e) => {
324                eprintln!("  Warning: glossary evidence collection failed: {e}");
325                None
326            }
327        };
328    }
329
330    // 1g. Explorer: treemap data
331    let mut explorer_data: Option<explorer::ExplorerData> = None;
332    if config.surfaces.contains(&Surface::Explorer) {
333        eprintln!("Building explorer treemap...");
334        explorer_data = match explorer::generate_explorer(cache) {
335            Ok(data) => Some(data),
336            Err(e) => {
337                eprintln!("  Warning: explorer generation failed: {e}");
338                None
339            }
340        };
341    }
342
343    // 1h. Project overview context
344    let overview_context = build_project_overview_context(cache, &wiki_page_index);
345
346    eprintln!(
347        "  Structural phase: {:.1}s",
348        structural_start.elapsed().as_secs_f64()
349    );
350
351    // These will be filled by Phase 2 narration (if enabled)
352    let mut architecture_narrative: Option<String> = None;
353    let mut project_overview: Option<String> = None;
354
355    // ══════════════════════════════════════════════════════════════
356    // Phase 2: Concurrent Narration (tokio, all at once)
357    // ══════════════════════════════════════════════════════════════
358    if let (Some(provider), Some(llm_cache)) = (provider.as_ref(), llm_cache.as_ref()) {
359        eprintln!("Collecting narration tasks...");
360
361        let mut narration_tasks: Vec<narrate::NarrationTask> = Vec::new();
362
363        // Wiki narration tasks
364        for pwc in &wiki_pages_with_context {
365            if let Some(ctx) = &pwc.narration_context {
366                narration_tasks.push(narrate::NarrationTask {
367                    system_prompt: narrate::wiki_system_prompt(),
368                    structural_context: ctx.clone(),
369                    snapshot_id: snapshot_id.to_string(),
370                    cache_key_suffix: pwc.page.module_path.clone(),
371                });
372            }
373        }
374
375        // Changelog narration task (single task for entire changelog)
376        if let Some(ref cl) = changelog_data
377            && !cl.raw_commits.is_empty()
378        {
379            let ctx = changelog::build_changelog_context(&cl.raw_commits, &cl.branch);
380            narration_tasks.push(narrate::NarrationTask {
381                system_prompt: narrate::changelog_system_prompt(),
382                structural_context: ctx,
383                snapshot_id: snapshot_id.to_string(),
384                cache_key_suffix: "changelog".to_string(),
385            });
386        }
387
388        // Architecture narrative task
389        if let Some(ref ctx) = arch_context {
390            narration_tasks.push(narrate::NarrationTask {
391                system_prompt: narrate::architecture_narrative_system_prompt(),
392                structural_context: ctx.clone(),
393                snapshot_id: snapshot_id.to_string(),
394                cache_key_suffix: "architecture-narrative".to_string(),
395            });
396        }
397
398        // Onboard narration task
399        if let Some(ref ob_data) = onboard_data {
400            let ctx = onboard::build_onboard_context(ob_data);
401            narration_tasks.push(narrate::NarrationTask {
402                system_prompt: narrate::onboard_system_prompt(),
403                structural_context: ctx,
404                snapshot_id: snapshot_id.to_string(),
405                cache_key_suffix: "onboard-guide".to_string(),
406            });
407        }
408
409        // Timeline narration task
410        if let Some(ref tl_data) = timeline_data {
411            let ctx = git_intel::build_timeline_context(tl_data);
412            narration_tasks.push(narrate::NarrationTask {
413                system_prompt: narrate::timeline_system_prompt(),
414                structural_context: ctx,
415                snapshot_id: snapshot_id.to_string(),
416                cache_key_suffix: "timeline-summary".to_string(),
417            });
418        }
419
420        // Glossary/Concepts: single product-concept task. The LLM receives
421        // structural evidence (modules + anchor symbols) and returns a JSON
422        // document containing the intro + 10-15 concepts with categories and
423        // related modules. Cache key bumped to `-v3` so v2 cache entries are
424        // bypassed.
425        if let Some(ref evidence) = glossary_evidence
426            && !evidence.modules.is_empty()
427        {
428            let concepts_ctx = glossary::build_concepts_context(evidence, &config.title);
429            narration_tasks.push(narrate::NarrationTask {
430                system_prompt: narrate::concepts_system_prompt(),
431                structural_context: concepts_ctx,
432                snapshot_id: snapshot_id.to_string(),
433                cache_key_suffix: "concepts-product-v3".to_string(),
434            });
435        }
436
437        // Project overview task
438        narration_tasks.push(narrate::NarrationTask {
439            system_prompt: narrate::project_overview_system_prompt(),
440            structural_context: overview_context,
441            snapshot_id: snapshot_id.to_string(),
442            cache_key_suffix: "project-overview".to_string(),
443        });
444
445        let task_count = narration_tasks.len();
446        eprintln!("Narrating {} tasks concurrently...", task_count);
447        let narration_start = std::time::Instant::now();
448
449        let results = narrate::narrate_batch(
450            Arc::clone(provider),
451            narration_tasks,
452            llm_cache,
453            config.concurrency,
454        );
455
456        eprintln!(
457            "  Narration phase: {:.1}s ({} tasks)",
458            narration_start.elapsed().as_secs_f64(),
459            task_count,
460        );
461
462        // Distribute results back to their sources
463        let result_map: std::collections::HashMap<String, Option<String>> = results
464            .into_iter()
465            .map(|r| (r.cache_key_suffix, r.response))
466            .collect();
467
468        // Fill wiki summaries
469        for pwc in &mut wiki_pages_with_context {
470            if let Some(response) = result_map.get(&pwc.page.module_path) {
471                pwc.page.sections.summary = response.clone();
472                if pwc.page.sections.summary.is_some() {
473                    has_narration = true;
474                }
475            }
476        }
477
478        // Fill changelog narration
479        if let Some(ref mut cl) = changelog_data
480            && let Some(Some(text)) = result_map.get("changelog")
481        {
482            cl.entries = changelog::parse_changelog_response(text, &cl.raw_commits);
483            cl.narrated = true;
484            has_narration = true;
485        }
486
487        // Extract architecture narrative and project overview
488        if let Some(response) = result_map.get("architecture-narrative") {
489            architecture_narrative = response.clone();
490            if architecture_narrative.is_some() {
491                has_narration = true;
492            }
493        }
494        if let Some(response) = result_map.get("project-overview") {
495            project_overview = response.clone();
496            if project_overview.is_some() {
497                has_narration = true;
498            }
499        }
500
501        // Fill onboard narration
502        if let Some(ref mut ob_data) = onboard_data
503            && let Some(response) = result_map.get("onboard-guide")
504        {
505            ob_data.narration = response.clone();
506            if ob_data.narration.is_some() {
507                has_narration = true;
508            }
509        }
510
511        // Fill timeline narration
512        if let Some(ref mut tl_data) = timeline_data
513            && let Some(response) = result_map.get("timeline-summary")
514        {
515            tl_data.narration = response.clone();
516            if tl_data.narration.is_some() {
517                has_narration = true;
518            }
519        }
520
521        // Parse the single concepts-product-v3 response into GlossaryData.
522        // On malformed JSON we log a warning and leave glossary_data as None
523        // so the page falls back to the no-LLM renderer (which still lists
524        // modules from the evidence bundle).
525        if let Some(Some(response)) = result_map.get("concepts-product-v3") {
526            match glossary::parse_concepts_response(response) {
527                Ok(parsed) => {
528                    let data: glossary::GlossaryData = parsed.into();
529                    if !data.concepts.is_empty() {
530                        has_narration = true;
531                    }
532                    glossary_data = Some(data);
533                }
534                Err(e) => {
535                    log::warn!("Failed to parse concepts JSON from LLM: {}", e);
536                    eprintln!(
537                        "  Warning: glossary LLM response was not valid JSON ({})",
538                        e
539                    );
540                }
541            }
542        }
543
544        // Update wiki page index descriptions with summaries
545        for (i, pwc) in wiki_pages_with_context.iter().enumerate() {
546            if let Some(summary) = &pwc.page.sections.summary
547                && i < wiki_page_index.len()
548            {
549                wiki_page_index[i].description = summary.chars().take(200).collect();
550            }
551        }
552    }
553
554    // ══════════════════════════════════════════════════════════════
555    // Phase 3: Write + Build
556    // ══════════════════════════════════════════════════════════════
557
558    // Write wiki pages
559    if config.surfaces.contains(&Surface::Wiki) {
560        write_wiki_section_index(&config.output_dir)?;
561
562        for (i, pwc) in wiki_pages_with_context.iter().enumerate() {
563            let module = module_map.get(pwc.page.module_path.as_str());
564            write_wiki_page(&config.output_dir, &pwc.page, module, i + 1)?;
565            pages_generated += 1;
566        }
567    }
568
569    // Write changelog
570    if let Some(ref cl) = changelog_data {
571        let changelog_md = changelog::render_markdown(cl);
572        write_changelog_page(&config.output_dir, &changelog_md, cl)?;
573        changelog_generated = true;
574    }
575
576    // Write map
577    if let Some(ref mc) = map_content {
578        write_map_page(
579            &config.output_dir,
580            mc,
581            layered_content.as_deref(),
582            architecture_narrative.as_deref(),
583        )?;
584        map_generated = true;
585    }
586
587    // Write onboard
588    if let Some(ref ob_data) = onboard_data {
589        let onboard_md = onboard::render_onboard_markdown(ob_data);
590        write_onboard_page(&config.output_dir, &onboard_md, ob_data)?;
591        onboard_generated = true;
592    }
593
594    // Write timeline
595    if let Some(ref tl_data) = timeline_data {
596        let timeline_md = git_intel::render_timeline_markdown(tl_data);
597        write_timeline_page(&config.output_dir, &timeline_md, tl_data)?;
598        timeline_generated = true;
599    }
600
601    // Write glossary: prefer the LLM-generated concept list, otherwise fall
602    // back to the evidence-based "--no-llm" placeholder. The page is still
603    // emitted in either case so site navigation stays consistent.
604    if config.surfaces.contains(&Surface::Glossary) {
605        let glossary_md = match (&glossary_data, &glossary_evidence) {
606            (Some(gl_data), _) if !gl_data.concepts.is_empty() => {
607                glossary::render_glossary_markdown(gl_data)
608            }
609            (_, Some(ev)) => glossary::render_glossary_no_llm(ev),
610            _ => glossary::render_glossary_markdown(&glossary::GlossaryData::default()),
611        };
612        write_glossary_page(&config.output_dir, &glossary_md)?;
613        glossary_generated = true;
614    }
615
616    // Write explorer
617    if let Some(ref exp_data) = explorer_data {
618        match explorer::render_explorer_markdown(exp_data) {
619            Ok(explorer_md) => {
620                write_explorer_page(&config.output_dir, &explorer_md)?;
621                explorer_generated = true;
622            }
623            Err(e) => {
624                log::warn!("Failed to render explorer: {}", e);
625            }
626        }
627    }
628
629    // Write home page (enhanced with new surfaces)
630    write_home_page(
631        &config.output_dir,
632        &config.title,
633        &config.base_url,
634        &wiki_page_index,
635        changelog_generated,
636        map_generated,
637        onboard_generated,
638        timeline_generated,
639        glossary_generated,
640        explorer_generated,
641        project_overview.as_deref(),
642        onboard_data.as_ref(),
643        timeline_data.as_ref(),
644    )?;
645
646    // Compute narration mode
647    let narration_mode = if config.no_llm {
648        "disabled".to_string()
649    } else if has_narration {
650        "narrated".to_string()
651    } else {
652        "structural".to_string()
653    };
654
655    // Try to build with Zola
656    let build_success = try_zola_build(&config.output_dir);
657    if build_success {
658        try_pagefind_build(&config.output_dir);
659        copy_pagefind_to_static(&config.output_dir);
660    }
661
662    eprintln!(
663        "  Total generation: {:.1}s",
664        overall_start.elapsed().as_secs_f64()
665    );
666
667    Ok(SiteReport {
668        output_dir: config.output_dir.display().to_string(),
669        pages_generated,
670        changelog_generated,
671        map_generated,
672        onboard_generated,
673        timeline_generated,
674        glossary_generated,
675        explorer_generated,
676        narration_mode,
677        build_success,
678    })
679}
680
681// ── Directory structure ──────────────────────────────────────
682
683fn create_directory_structure(output_dir: &Path) -> Result<()> {
684    let dirs = [
685        "",
686        "content",
687        "content/wiki",
688        "content/changelog",
689        "content/map",
690        "content/onboard",
691        "content/timeline",
692        "content/glossary",
693        "content/explorer",
694        "templates",
695        "templates/shortcodes",
696        "static",
697        "sass",
698    ];
699
700    for dir in &dirs {
701        std::fs::create_dir_all(output_dir.join(dir))
702            .with_context(|| format!("Failed to create directory: {}", dir))?;
703    }
704
705    Ok(())
706}
707
708// ── Zola config ──────────────────────────────────────────────
709
710fn write_zola_config(
711    output_dir: &Path,
712    base_url: &str,
713    title: &str,
714    surfaces: &[Surface],
715) -> Result<()> {
716    let config = format!(
717        r#"# Zola configuration — generated by rfx pulse generate
718base_url = "{base_url}"
719title = "{title}"
720description = "Auto-generated codebase documentation"
721compile_sass = false
722build_search_index = false
723generate_feeds = false
724minify_html = false
725
726[markdown]
727highlight_code = true
728highlight_theme = "base16-ocean-dark"
729render_emoji = false
730external_links_target_blank = true
731smart_punctuation = true
732
733[slugify]
734paths = "safe"
735
736[extra]
737generated_by = "Reflex Pulse"
738has_onboard = {onboard}
739has_glossary = {glossary}
740has_changelog = {changelog}
741has_timeline = {timeline}
742has_map = {map}
743has_explorer = {explorer}
744"#,
745        onboard = surfaces.contains(&Surface::Onboard),
746        glossary = surfaces.contains(&Surface::Glossary),
747        changelog = surfaces.contains(&Surface::Changelog),
748        timeline = surfaces.contains(&Surface::Timeline),
749        map = surfaces.contains(&Surface::Map),
750        explorer = surfaces.contains(&Surface::Explorer),
751    );
752
753    std::fs::write(output_dir.join("config.toml"), config)
754        .context("Failed to write Zola config.toml")
755}
756
757// ── Templates ────────────────────────────────────────────────
758
759fn write_templates(output_dir: &Path) -> Result<()> {
760    // Base template with hierarchical sidebar, favicon, mobile hamburger
761    let base_html = r##"<!DOCTYPE html>
762<html lang="en">
763<head>
764    <meta charset="utf-8">
765    <meta name="viewport" content="width=device-width, initial-scale=1">
766    <title>{% block title %}{{ config.title }}{% endblock title %}</title>
767    <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M4 16 Q8 8 12 16 Q16 24 20 16 Q24 8 28 16' fill='none' stroke='%237aa2f7' stroke-width='3' stroke-linecap='round'/></svg>">
768    <link rel="stylesheet" href="{{ get_url(path='style.css') }}">
769    <link rel="stylesheet" href="{{ get_url(path='pagefind/pagefind-component-ui.css') }}">
770    <script src="{{ get_url(path='pagefind/pagefind-component-ui.js') }}" type="module"></script>
771</head>
772<body data-pf-theme="dark">
773    <button class="mobile-menu-toggle" aria-label="Toggle menu" onclick="document.body.classList.toggle('sidebar-open')">
774        <span></span><span></span><span></span>
775    </button>
776    <div class="layout">
777        <nav class="sidebar">
778            <div class="sidebar-header">
779                <a href="{{ get_url(path='/') }}"><h2>{{ config.title }}</h2></a>
780                <pagefind-modal-trigger>Search</pagefind-modal-trigger>
781                <pagefind-modal bundle-path="{{ get_url(path='pagefind/') }}"></pagefind-modal>
782            </div>
783            <ul class="nav-list">
784                <li><a href="{{ get_url(path='/') }}" {% if current_path == "/" %}class="active"{% endif %}>Home</a></li>
785                {% set wiki_section = get_section(path="wiki/_index.md") %}
786                <li class="nav-section">
787                    <a href="{{ get_url(path='wiki') }}" {% if current_path is starting_with("wiki") %}class="active"{% endif %}>Reference</a>
788                    <ul class="nav-wiki-tree">
789                        {# Build hierarchical sidebar: Tier 1 modules #}
790                        {% for page in wiki_section.pages %}
791                            {% if page.extra.tier is defined and page.extra.tier == 1 %}
792                                {% set parent_name = page.title | trim_end_matches(pat="/") %}
793                                {% set children = wiki_section.pages | filter(attribute="extra.parent_path", value=parent_name) %}
794                                {% if children | length > 0 %}
795                                {# Tier 1 WITH children: collapsible #}
796                                <li class="nav-tier1">
797                                    <details {% if current_path is starting_with(page.path) %}open{% endif %}>
798                                        <summary>
799                                            <a href="{{ page.permalink }}" {% if current_path == page.path %}class="active"{% endif %}>{{ page.title }}</a>
800                                        </summary>
801                                        <ul>
802                                            {% for child in children %}
803                                            <li><a href="{{ child.permalink }}" {% if current_path == child.path %}class="active"{% endif %}>{{ child.title }}</a></li>
804                                            {% endfor %}
805                                        </ul>
806                                    </details>
807                                </li>
808                                {% else %}
809                                {# Tier 1 WITHOUT children: plain link #}
810                                <li class="nav-tier1"><a href="{{ page.permalink }}" {% if current_path == page.path %}class="active"{% endif %}>{{ page.title }}</a></li>
811                                {% endif %}
812                            {% endif %}
813                        {% endfor %}
814                        {# Orphan Tier 2 pages (no parent_path set in front matter) #}
815                        {% for page in wiki_section.pages %}
816                            {% if page.extra.tier is defined and page.extra.tier == 2 and page.extra.parent_path is undefined %}
817                            <li><a href="{{ page.permalink }}" {% if current_path == page.path %}class="active"{% endif %}>{{ page.title }}</a></li>
818                            {% endif %}
819                        {% endfor %}
820                    </ul>
821                </li>
822                {% if config.extra.has_onboard %}
823                <li><a href="{{ get_url(path='onboard') }}" {% if current_path is starting_with("onboard") %}class="active"{% endif %}>Onboard</a></li>
824                {% endif %}
825                {% if config.extra.has_glossary %}
826                <li><a href="{{ get_url(path='glossary') }}" {% if current_path is starting_with("glossary") %}class="active"{% endif %}>Glossary</a></li>
827                {% endif %}
828                {% if config.extra.has_changelog %}
829                <li><a href="{{ get_url(path='changelog') }}" {% if current_path is starting_with("changelog") %}class="active"{% endif %}>Changelog</a></li>
830                {% endif %}
831                {% if config.extra.has_timeline %}
832                <li><a href="{{ get_url(path='timeline') }}" {% if current_path is starting_with("timeline") %}class="active"{% endif %}>Timeline</a></li>
833                {% endif %}
834                {% if config.extra.has_map %}
835                <li><a href="{{ get_url(path='map') }}" {% if current_path is starting_with("map") %}class="active"{% endif %}>Map</a></li>
836                {% endif %}
837                {% if config.extra.has_explorer %}
838                <li><a href="{{ get_url(path='explorer') }}" {% if current_path is starting_with("explorer") %}class="active"{% endif %}>Explorer</a></li>
839                {% endif %}
840            </ul>
841        </nav>
842        <main class="content">
843            {% block content %}{% endblock content %}
844        </main>
845    </div>
846    {% block scripts %}{% endblock scripts %}
847</body>
848</html>"##;
849
850    // Index (home) template
851    let index_html = r#"{% extends "base.html" %}
852{% block title %}{{ config.title }}{% endblock title %}
853{% block content %}
854{{ section.content | safe }}
855{% endblock content %}"#;
856
857    // Section template (wiki/, changelog/, map/)
858    let section_html = r#"{% extends "base.html" %}
859{% block title %}{{ section.title }} — {{ config.title }}{% endblock title %}
860{% block content %}
861<h1>{{ section.title }}</h1>
862{{ section.content | safe }}
863{% if section.pages %}
864{% if section.extra.has_search_filter is defined %}
865<div class="search-filter">
866    <input type="text" id="module-search" placeholder="Filter modules..." aria-label="Filter modules" autocomplete="off">
867    <span class="search-count" id="search-count"></span>
868</div>
869{% endif %}
870<div class="page-list" id="page-list">
871    {% for page in section.pages %}
872    <div class="page-card" data-title="{{ page.title | lower }}">
873        <h3><a href="{{ page.permalink }}">{{ page.title }}</a></h3>
874        {% if page.description %}
875        <p>{{ page.description }}</p>
876        {% endif %}
877    </div>
878    {% endfor %}
879</div>
880{% endif %}
881{% endblock content %}
882{% block scripts %}
883{% if section.extra.has_search_filter is defined %}
884<script>
885(function() {
886    var input = document.getElementById('module-search');
887    var cards = document.querySelectorAll('.page-card');
888    var count = document.getElementById('search-count');
889    if (!input) return;
890    input.addEventListener('input', function() {
891        var q = this.value.toLowerCase();
892        var visible = 0;
893        cards.forEach(function(card) {
894            var match = !q || card.getAttribute('data-title').indexOf(q) !== -1;
895            card.style.display = match ? '' : 'none';
896            if (match) visible++;
897        });
898        count.textContent = q ? visible + ' of ' + cards.length : '';
899    });
900})();
901</script>
902{% endif %}
903{% if section.extra.has_mermaid is defined %}
904<script type="module">
905    import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs';
906    mermaid.initialize({
907        startOnLoad: true,
908        theme: 'base',
909        themeVariables: {
910            primaryColor: '#1a1a2e',
911            primaryTextColor: '#e0e0e0',
912            primaryBorderColor: '#a78bfa',
913            lineColor: '#8888a8',
914            secondaryColor: '#252542',
915            tertiaryColor: '#0d0d0d',
916            edgeLabelBackground: 'transparent',
917            clusterBkg: '#1a1a2e',
918            clusterBorder: '#2a2a4a',
919            fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
920            fontSize: '14px',
921            xyChart: {
922                backgroundColor: 'transparent',
923                titleColor: '#e0e0e0',
924                xAxisLabelColor: '#8888a8',
925                yAxisLabelColor: '#8888a8',
926                xAxisTitleColor: '#e0e0e0',
927                yAxisTitleColor: '#e0e0e0',
928                xAxisTickColor: '#2a2a4a',
929                yAxisTickColor: '#2a2a4a',
930                xAxisLineColor: '#2a2a4a',
931                yAxisLineColor: '#2a2a4a',
932                plotColorPalette: '#a78bfa'
933            }
934        },
935        securityLevel: 'loose'
936    });
937</script>
938{% endif %}
939{% endblock scripts %}"#;
940
941    // Page template (individual wiki modules) with breadcrumbs
942    let page_html = r#"{% extends "base.html" %}
943{% block title %}{{ page.title }} — {{ config.title }}{% endblock title %}
944{% block content %}
945<nav class="breadcrumbs" aria-label="Breadcrumb">
946    <a href="{{ get_url(path='/') }}">Home</a>
947    <span class="sep">/</span>
948    <a href="{{ get_url(path='wiki/') }}">Reference</a>
949    {% if page.extra.parent_path is defined %}
950    <span class="sep">/</span>
951    {% set parent_slug = page.extra.parent_path | replace(from="/", to="-") %}
952    <a href="{{ get_url(path='wiki/' ~ parent_slug ~ '/') }}">{{ page.extra.parent_path }}/</a>
953    {% endif %}
954    <span class="sep">/</span>
955    <span class="current">{{ page.title }}</span>
956</nav>
957
958<h1>{{ page.title }}</h1>
959{% if page.extra.tier %}
960<div class="page-meta">
961    <span class="badge tier-{{ page.extra.tier }}">Tier {{ page.extra.tier }}</span>
962    {% if page.extra.file_count %}
963    <span class="badge">{{ page.extra.file_count }} files</span>
964    {% endif %}
965    {% if page.extra.languages %}
966    <span class="badge">{{ page.extra.languages }}</span>
967    {% endif %}
968</div>
969{% endif %}
970{{ page.content | safe }}
971{% endblock content %}
972{% block scripts %}
973{% if page.extra.has_mermaid is defined %}
974<script type="module">
975    import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs';
976    mermaid.initialize({
977        startOnLoad: true,
978        theme: 'base',
979        themeVariables: {
980            primaryColor: '#1a1a2e',
981            primaryTextColor: '#e0e0e0',
982            primaryBorderColor: '#a78bfa',
983            lineColor: '#8888a8',
984            secondaryColor: '#252542',
985            tertiaryColor: '#0d0d0d',
986            edgeLabelBackground: 'transparent',
987            clusterBkg: '#1a1a2e',
988            clusterBorder: '#2a2a4a',
989            fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
990            fontSize: '14px',
991            xyChart: {
992                backgroundColor: 'transparent',
993                titleColor: '#e0e0e0',
994                xAxisLabelColor: '#8888a8',
995                yAxisLabelColor: '#8888a8',
996                xAxisTitleColor: '#e0e0e0',
997                yAxisTitleColor: '#e0e0e0',
998                xAxisTickColor: '#2a2a4a',
999                yAxisTickColor: '#2a2a4a',
1000                xAxisLineColor: '#2a2a4a',
1001                yAxisLineColor: '#2a2a4a',
1002                plotColorPalette: '#a78bfa'
1003            }
1004        },
1005        securityLevel: 'loose'
1006    });
1007</script>
1008{% endif %}
1009{% endblock scripts %}"#;
1010
1011    // Mermaid shortcode
1012    let mermaid_shortcode = r#"<pre class="mermaid">
1013{{ body }}
1014</pre>"#;
1015
1016    std::fs::write(output_dir.join("templates/base.html"), base_html)?;
1017    std::fs::write(output_dir.join("templates/index.html"), index_html)?;
1018    std::fs::write(output_dir.join("templates/section.html"), section_html)?;
1019    std::fs::write(output_dir.join("templates/page.html"), page_html)?;
1020    std::fs::write(
1021        output_dir.join("templates/shortcodes/mermaid.html"),
1022        mermaid_shortcode,
1023    )?;
1024
1025    Ok(())
1026}
1027
1028// ── Static assets ────────────────────────────────────────────
1029
1030fn write_static_assets(output_dir: &Path) -> Result<()> {
1031    let css = r#":root {
1032    --bg: #0d0d0d;
1033    --bg-surface: #1a1a2e;
1034    --bg-hover: #252542;
1035    --bg-elevated: #141428;
1036    --fg: #e0e0e0;
1037    --fg-muted: #8888a8;
1038    --fg-accent: #a78bfa;
1039    --fg-green: #4ade80;
1040    --fg-yellow: #fbbf24;
1041    --fg-red: #fb7185;
1042    --fg-pink: #f472b6;
1043    --fg-cyan: #67e8f9;
1044    --border: #2a2a4a;
1045    --sidebar-width: 270px;
1046}
1047
1048* { margin: 0; padding: 0; box-sizing: border-box; }
1049
1050body {
1051    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
1052    background: var(--bg);
1053    color: var(--fg);
1054    line-height: 1.7;
1055}
1056
1057/* ── Layout ──────────────────────────────────── */
1058
1059.layout {
1060    display: flex;
1061    min-height: 100vh;
1062}
1063
1064.sidebar {
1065    width: var(--sidebar-width);
1066    background: linear-gradient(180deg, var(--bg-surface) 0%, var(--bg-elevated) 100%);
1067    border-right: 1px solid var(--border);
1068    border-top: 2px solid var(--fg-accent);
1069    box-shadow: inset 0 2px 12px rgba(167, 139, 250, 0.06);
1070    padding: 1.5rem 0;
1071    position: fixed;
1072    height: 100vh;
1073    overflow-y: auto;
1074    z-index: 100;
1075    scrollbar-width: thin;
1076    scrollbar-color: var(--border) transparent;
1077}
1078
1079.sidebar::-webkit-scrollbar { width: 6px; }
1080.sidebar::-webkit-scrollbar-track { background: transparent; }
1081.sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
1082.sidebar::-webkit-scrollbar-thumb:hover { background: var(--fg-muted); }
1083
1084.sidebar-header {
1085    padding: 0 1.25rem 1rem;
1086    border-bottom: 1px solid var(--border);
1087    margin-bottom: 0.25rem;
1088}
1089
1090.sidebar-header h2 {
1091    font-size: 1.1rem;
1092    color: var(--fg-accent);
1093    letter-spacing: 0.02em;
1094}
1095
1096.sidebar a {
1097    color: var(--fg);
1098    text-decoration: none;
1099    transition: color 0.15s;
1100}
1101
1102.sidebar a:hover {
1103    color: var(--fg-accent);
1104}
1105
1106.sidebar a.active {
1107    color: var(--fg-accent);
1108    font-weight: 600;
1109    border-left: 2px solid var(--fg-accent);
1110    padding-left: 0.5rem;
1111    margin-left: -0.5rem;
1112    text-shadow: 0 0 8px rgba(167, 139, 250, 0.4);
1113}
1114
1115/* ── Sidebar nav ─────────────────────────────── */
1116
1117.nav-list {
1118    list-style: none;
1119    padding: 0;
1120}
1121
1122.nav-list > li {
1123    padding: 0.35rem 1.25rem;
1124}
1125
1126.nav-list > li > a {
1127    font-weight: 500;
1128    font-size: 0.95rem;
1129}
1130
1131.nav-section ul {
1132    list-style: none;
1133    padding-left: 0.75rem;
1134    margin-top: 0.25rem;
1135}
1136
1137.nav-section ul li {
1138    padding: 0.15rem 0;
1139}
1140
1141.nav-section ul li a {
1142    font-size: 0.85rem;
1143    color: var(--fg-muted);
1144}
1145
1146.nav-section ul li a:hover {
1147    color: var(--fg-accent);
1148}
1149
1150/* Hierarchical wiki tree */
1151.nav-wiki-tree {
1152    list-style: none;
1153    padding-left: 0;
1154    margin-top: 0.25rem;
1155}
1156
1157.nav-wiki-tree .nav-tier1 {
1158    margin-bottom: 0.1rem;
1159}
1160
1161.nav-wiki-tree .nav-tier1 > a {
1162    font-size: 0.9rem;
1163    display: block;
1164    padding: 0.2rem 0;
1165}
1166
1167.nav-wiki-tree details {
1168    margin-bottom: 0;
1169}
1170
1171.nav-wiki-tree details summary {
1172    display: flex;
1173    align-items: center;
1174    gap: 0.25rem;
1175    padding: 0.2rem 0;
1176    font-size: 0.9rem;
1177}
1178
1179.nav-wiki-tree details summary::before {
1180    content: "▸";
1181    flex-shrink: 0;
1182    font-size: 0.75rem;
1183}
1184
1185.nav-wiki-tree details[open] summary::before {
1186    content: "▾";
1187}
1188
1189.nav-wiki-tree details summary a {
1190    display: inline;
1191}
1192
1193.nav-wiki-tree details ul {
1194    list-style: none;
1195    padding-left: 1rem;
1196}
1197
1198.nav-wiki-tree details ul li {
1199    padding: 0.1rem 0;
1200}
1201
1202.nav-wiki-tree details ul li a {
1203    font-size: 0.8rem;
1204    color: var(--fg-muted);
1205}
1206
1207.nav-section ul li a {
1208    display: block;
1209    white-space: nowrap;
1210    overflow: hidden;
1211    text-overflow: ellipsis;
1212    max-width: 190px;
1213}
1214
1215/* ── Content area ────────────────────────────── */
1216
1217.content {
1218    margin-left: var(--sidebar-width);
1219    padding: 2rem 3rem;
1220    max-width: 960px;
1221    flex: 1;
1222}
1223
1224/* ── Typography ──────────────────────────────── */
1225
1226h1 {
1227    font-size: 1.8rem;
1228    margin-bottom: 1rem;
1229    color: var(--fg);
1230    letter-spacing: -0.01em;
1231}
1232
1233h2 {
1234    font-size: 1.4rem;
1235    margin: 2rem 0 0.75rem;
1236    color: var(--fg-accent);
1237    border-bottom: 1px solid transparent;
1238    border-image: linear-gradient(90deg, var(--fg-accent), var(--fg-pink)) 1;
1239    padding-bottom: 0.4rem;
1240}
1241
1242h3 {
1243    font-size: 1.15rem;
1244    margin: 1.5rem 0 0.5rem;
1245    color: var(--fg);
1246}
1247
1248p { margin-bottom: 0.75rem; }
1249
1250a { color: var(--fg-accent); text-decoration: none; transition: color 0.15s; }
1251a:hover { text-decoration: underline; }
1252
1253code {
1254    background: var(--bg-surface);
1255    padding: 0.15em 0.4em;
1256    border-radius: 3px;
1257    font-size: 0.9em;
1258    font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
1259    font-feature-settings: "liga" 1, "calt" 1;
1260}
1261
1262pre {
1263    background: var(--bg-surface);
1264    border: 1px solid var(--border);
1265    border-radius: 8px;
1266    padding: 1rem;
1267    overflow-x: auto;
1268    margin: 1rem 0;
1269    box-shadow: 0 0 8px rgba(167, 139, 250, 0.06);
1270}
1271
1272pre code {
1273    background: none;
1274    padding: 0;
1275}
1276
1277/* ── Tables ──────────────────────────────────── */
1278
1279table {
1280    width: 100%;
1281    border-collapse: collapse;
1282    margin: 1rem 0;
1283    font-size: 0.95rem;
1284}
1285
1286th, td {
1287    text-align: left;
1288    padding: 0.6rem 0.85rem;
1289    border: 1px solid var(--border);
1290}
1291
1292th {
1293    background: var(--bg-elevated);
1294    font-weight: 600;
1295    color: var(--fg-accent);
1296    font-size: 0.85rem;
1297    text-transform: uppercase;
1298    letter-spacing: 0.03em;
1299}
1300
1301tr:nth-child(even) { background: var(--bg-elevated); }
1302tr:hover { background: var(--bg-hover); }
1303
1304td:first-child {
1305    border-left: 2px solid var(--border);
1306}
1307
1308/* ── Lists ───────────────────────────────────── */
1309
1310ul, ol { padding-left: 1.5rem; margin-bottom: 0.75rem; }
1311li { margin-bottom: 0.3rem; }
1312
1313/* ── Badges ──────────────────────────────────── */
1314
1315.page-meta {
1316    display: flex;
1317    gap: 0.5rem;
1318    margin-bottom: 1.5rem;
1319    flex-wrap: wrap;
1320}
1321
1322.badge {
1323    display: inline-block;
1324    padding: 0.2rem 0.65rem;
1325    border-radius: 12px;
1326    font-size: 0.8rem;
1327    font-weight: 500;
1328    background: var(--bg-surface);
1329    border: 1px solid var(--border);
1330    color: var(--fg-muted);
1331}
1332
1333.tier-1 { color: var(--fg-accent); border-color: var(--fg-accent); background: rgba(167, 139, 250, 0.1); box-shadow: 0 0 6px rgba(167, 139, 250, 0.15); }
1334.tier-2 { color: var(--fg-green); border-color: var(--fg-green); background: rgba(74, 222, 128, 0.1); box-shadow: 0 0 6px rgba(74, 222, 128, 0.15); }
1335
1336/* ── Breadcrumbs ─────────────────────────────── */
1337
1338.breadcrumbs {
1339    font-size: 0.85rem;
1340    color: var(--fg-muted);
1341    margin-bottom: 1rem;
1342    padding: 0.5rem 0;
1343}
1344
1345.breadcrumbs a {
1346    color: var(--fg-muted);
1347    transition: color 0.15s;
1348}
1349
1350.breadcrumbs a:hover {
1351    color: var(--fg-accent);
1352    text-decoration: none;
1353}
1354
1355.breadcrumbs .sep {
1356    margin: 0 0.4rem;
1357    color: var(--border);
1358}
1359
1360.breadcrumbs .current {
1361    color: var(--fg);
1362    font-weight: 500;
1363}
1364
1365/* ── Search filter ───────────────────────────── */
1366
1367.search-filter {
1368    display: flex;
1369    align-items: center;
1370    gap: 0.75rem;
1371    margin-bottom: 1rem;
1372}
1373
1374.search-filter input {
1375    flex: 1;
1376    max-width: 400px;
1377    padding: 0.5rem 0.75rem;
1378    background: var(--bg-surface);
1379    border: 1px solid var(--border);
1380    border-radius: 6px;
1381    color: var(--fg);
1382    font-size: 0.9rem;
1383    outline: none;
1384    transition: border-color 0.2s;
1385}
1386
1387.search-filter input:focus {
1388    border-color: var(--fg-accent);
1389}
1390
1391.search-filter input::placeholder {
1392    color: var(--fg-muted);
1393}
1394
1395.search-count {
1396    font-size: 0.8rem;
1397    color: var(--fg-muted);
1398}
1399
1400/* ── Cards ───────────────────────────────────── */
1401
1402.page-card {
1403    padding: 1rem;
1404    border: 1px solid var(--border);
1405    border-radius: 8px;
1406    margin-bottom: 0.75rem;
1407    background: var(--bg-surface);
1408    transition: background 0.15s, border-color 0.15s;
1409}
1410
1411.page-card:hover {
1412    background: var(--bg-hover);
1413    border-color: var(--fg-accent);
1414    box-shadow: 0 0 10px rgba(167, 139, 250, 0.1);
1415}
1416
1417.page-card h3 { margin: 0 0 0.25rem; font-size: 1rem; }
1418.page-card p { margin: 0; font-size: 0.9rem; color: var(--fg-muted); }
1419
1420/* ── Mermaid diagrams ────────────────────────── */
1421
1422.mermaid {
1423    background: var(--bg-surface);
1424    padding: 2rem;
1425    border-radius: 8px;
1426    text-align: center;
1427    border: 1px solid var(--border);
1428    overflow: auto;
1429    max-height: 80vh;
1430    cursor: grab;
1431    position: relative;
1432}
1433
1434.mermaid svg {
1435    max-width: none;
1436    min-width: 100%;
1437}
1438
1439/* Edge labels */
1440.mermaid .edgeLabel {
1441    background: var(--bg-surface) !important;
1442    color: var(--fg) !important;
1443}
1444
1445/* ── Metric cards (changelog summary) ──────────── */
1446
1447.metric-cards {
1448    display: flex;
1449    gap: 1rem;
1450    margin: 1.5rem 0;
1451    flex-wrap: wrap;
1452}
1453
1454.metric-card {
1455    flex: 1;
1456    min-width: 120px;
1457    padding: 1rem 1.25rem;
1458    background: var(--bg-surface);
1459    border: 1px solid var(--border);
1460    border-radius: 8px;
1461    text-align: center;
1462}
1463
1464.metric-value {
1465    font-size: 1.6rem;
1466    font-weight: 700;
1467    color: var(--fg-accent);
1468    line-height: 1.2;
1469}
1470
1471.metric-card:nth-child(4n+1) .metric-value { color: var(--fg-accent); }
1472.metric-card:nth-child(4n+2) .metric-value { color: var(--fg-pink); }
1473.metric-card:nth-child(4n+3) .metric-value { color: var(--fg-green); }
1474.metric-card:nth-child(4n+4) .metric-value { color: var(--fg-yellow); }
1475
1476.metric-label {
1477    font-size: 0.8rem;
1478    color: var(--fg-muted);
1479    text-transform: uppercase;
1480    letter-spacing: 0.05em;
1481    margin-top: 0.25rem;
1482}
1483
1484/* ── Module grid ─────────────────────────────── */
1485
1486.module-grid {
1487    display: grid;
1488    grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
1489    gap: 1rem;
1490    margin: 1rem 0;
1491}
1492
1493.module-card {
1494    padding: 1.25rem;
1495    border: 1px solid var(--border);
1496    border-radius: 8px;
1497    background: var(--bg-surface);
1498    transition: border-color 0.2s, background 0.2s, transform 0.2s;
1499}
1500
1501.module-card:hover {
1502    background: var(--bg-hover);
1503    border-color: var(--fg-accent);
1504    transform: translateY(-1px);
1505    box-shadow: 0 0 12px rgba(167, 139, 250, 0.1);
1506}
1507
1508.module-card h3 {
1509    margin: 0 0 0.25rem;
1510    font-size: 1.05rem;
1511}
1512
1513.module-card h3 a { color: var(--fg); }
1514.module-card h3 a:hover { color: var(--fg-accent); text-decoration: none; }
1515
1516.module-stats {
1517    font-size: 0.8rem;
1518    color: var(--fg-muted);
1519    margin-bottom: 0.5rem;
1520}
1521
1522.module-card p {
1523    margin: 0;
1524    font-size: 0.9rem;
1525    color: var(--fg-muted);
1526    line-height: 1.5;
1527}
1528
1529.tier-1-card {
1530    border-left: 3px solid var(--fg-accent);
1531}
1532
1533.sub-modules {
1534    list-style: none;
1535    padding: 0.5rem 0 0 0;
1536    margin: 0;
1537    border-top: 1px solid var(--border);
1538    margin-top: 0.75rem;
1539}
1540
1541.sub-modules li {
1542    padding: 0.15rem 0;
1543    font-size: 0.85rem;
1544}
1545
1546.sub-modules li a { color: var(--fg-muted); }
1547.sub-modules li a:hover { color: var(--fg-accent); }
1548
1549.sub-stats {
1550    color: var(--fg-muted);
1551    font-size: 0.75rem;
1552}
1553
1554/* ── Collapsible details/summary ─────────────── */
1555
1556details {
1557    margin-bottom: 0.5rem;
1558}
1559
1560details summary {
1561    cursor: pointer;
1562    padding: 0.3rem 0;
1563    color: var(--fg);
1564    list-style: none;
1565    transition: color 0.15s;
1566}
1567
1568details summary:hover { color: var(--fg-accent); }
1569
1570details summary::-webkit-details-marker { display: none; }
1571
1572details summary::before {
1573    content: "▸ ";
1574    color: var(--fg-muted);
1575    transition: color 0.15s;
1576}
1577
1578details[open] summary::before {
1579    content: "▾ ";
1580}
1581
1582details[open] {
1583    padding-bottom: 0.25rem;
1584}
1585
1586/* ── Doc comments in key symbols ──────────── */
1587
1588.doc-comment {
1589    margin: 0.25rem 0 0.5rem 1.5rem;
1590    padding: 0.4rem 0.75rem;
1591    background: var(--surface);
1592    border-left: 2px solid var(--border);
1593    border-radius: 0 4px 4px 0;
1594    color: var(--fg-muted);
1595    font-size: 0.82rem;
1596    line-height: 1.5;
1597}
1598
1599.doc-comment p {
1600    margin: 0.2rem 0;
1601}
1602
1603.doc-comment code {
1604    font-size: 0.8rem;
1605    background: rgba(255,255,255,0.04);
1606}
1607
1608.doc-comment-inline {
1609    color: var(--fg-muted);
1610    font-size: 0.85rem;
1611}
1612
1613/* ── Map-specific: full width for diagrams ──── */
1614
1615.map-diagram {
1616    max-width: none;
1617}
1618
1619/* ── Diagram view toggle ─────────────────────── */
1620
1621.diagram-tabs {
1622    display: flex;
1623    gap: 0;
1624    margin-bottom: 0;
1625    border-bottom: 1px solid var(--border);
1626}
1627
1628.diagram-tab {
1629    padding: 0.5rem 1.25rem;
1630    cursor: pointer;
1631    font-size: 0.9rem;
1632    color: var(--fg-muted);
1633    border-bottom: 2px solid transparent;
1634    transition: color 0.15s, border-color 0.15s;
1635    background: none;
1636    border-top: none;
1637    border-left: none;
1638    border-right: none;
1639    font-family: inherit;
1640}
1641
1642.diagram-tab:hover { color: var(--fg); }
1643.diagram-tab.active { color: var(--fg-accent); border-bottom-color: var(--fg-accent); }
1644
1645.diagram-panel { display: none; }
1646.diagram-panel.active { display: block; }
1647
1648/* ── Mobile hamburger ────────────────────────── */
1649
1650.mobile-menu-toggle {
1651    display: none;
1652    position: fixed;
1653    top: 0.75rem;
1654    left: 0.75rem;
1655    z-index: 200;
1656    background: var(--bg-surface);
1657    border: 1px solid var(--border);
1658    border-radius: 6px;
1659    padding: 0.5rem;
1660    cursor: pointer;
1661    width: 36px;
1662    height: 36px;
1663    flex-direction: column;
1664    justify-content: center;
1665    align-items: center;
1666    gap: 4px;
1667}
1668
1669.mobile-menu-toggle span {
1670    display: block;
1671    width: 18px;
1672    height: 2px;
1673    background: var(--fg);
1674    border-radius: 1px;
1675    transition: transform 0.2s, opacity 0.2s;
1676}
1677
1678/* ── Responsive ──────────────────────────────── */
1679
1680@media (max-width: 768px) {
1681    .mobile-menu-toggle { display: flex; }
1682
1683    .sidebar {
1684        transform: translateX(-100%);
1685        transition: transform 0.25s ease;
1686    }
1687
1688    .sidebar-open .sidebar {
1689        transform: translateX(0);
1690    }
1691
1692    .content {
1693        margin-left: 0;
1694        padding: 3.5rem 1rem 1rem;
1695    }
1696
1697    .module-grid { grid-template-columns: 1fr; }
1698
1699    .mermaid { padding: 1rem; }
1700}
1701
1702/* ── Stats row ──────────────────────────────── */
1703
1704.stats-row {
1705    display: flex;
1706    gap: 1rem;
1707    margin: 1.5rem 0;
1708    flex-wrap: wrap;
1709}
1710
1711.stat-card {
1712    flex: 1;
1713    min-width: 100px;
1714    padding: 1rem;
1715    background: var(--bg-surface);
1716    border: 1px solid var(--border);
1717    border-radius: 8px;
1718    text-align: center;
1719}
1720
1721.stat-value {
1722    font-size: 1.8rem;
1723    font-weight: 700;
1724    color: var(--fg-accent);
1725    line-height: 1.2;
1726}
1727
1728.stat-card:nth-child(4n+1) .stat-value { color: var(--fg-accent); }
1729.stat-card:nth-child(4n+2) .stat-value { color: var(--fg-pink); }
1730.stat-card:nth-child(4n+3) .stat-value { color: var(--fg-green); }
1731.stat-card:nth-child(4n+4) .stat-value { color: var(--fg-yellow); }
1732
1733.stat-label {
1734    font-size: 0.75rem;
1735    color: var(--fg-muted);
1736    text-transform: uppercase;
1737    letter-spacing: 0.05em;
1738    margin-top: 0.25rem;
1739}
1740
1741/* ── Quick links ────────────────────────────── */
1742
1743.quick-links {
1744    display: grid;
1745    grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
1746    gap: 0.75rem;
1747    margin: 1rem 0;
1748}
1749
1750.quick-link {
1751    display: block;
1752    padding: 1rem;
1753    background: var(--bg-surface);
1754    border: 1px solid var(--border);
1755    border-radius: 8px;
1756    color: var(--fg);
1757    text-decoration: none;
1758    transition: border-color 0.2s, background 0.2s;
1759    font-size: 0.9rem;
1760}
1761
1762.quick-link:hover {
1763    background: var(--bg-hover);
1764    border-color: var(--fg-accent);
1765}
1766
1767.quick-link strong {
1768    color: var(--fg-accent);
1769}
1770
1771/* ── Pagefind search ────────────────────────── */
1772pagefind-modal {
1773    --pf-modal-max-width: 700px;
1774    --pf-text: var(--fg);
1775    --pf-text-secondary: #b0b0c8;
1776    --pf-text-muted: #8888a8;
1777    --pf-background: var(--bg-surface);
1778    --pf-border: var(--border);
1779    --pf-border-focus: #3a3a5a;
1780    --pf-border-radius: 6px;
1781    --pf-hover: var(--bg-hover);
1782    --pf-mark: var(--fg-accent);
1783    --pf-skeleton: var(--bg-hover);
1784    --pf-skeleton-shine: var(--border);
1785    --pf-outline-focus: var(--fg-accent);
1786    --pf-scroll-shadow: rgba(0, 0, 0, 0.4);
1787    --pf-modal-backdrop: rgba(0, 0, 0, 0.8);
1788}
1789
1790.sidebar-header pagefind-modal-trigger {
1791    display: block;
1792    margin-top: 0.5rem;
1793    background: transparent;
1794    border: none;
1795    outline: none;
1796    cursor: pointer;
1797}
1798"#;
1799
1800    std::fs::write(output_dir.join("static/style.css"), css).context("Failed to write style.css")
1801}
1802
1803// ── Content generation ───────────────────────────────────────
1804
1805struct WikiPageMeta {
1806    title: String,
1807    slug: String,
1808    file_count: usize,
1809    total_lines: usize,
1810    description: String,
1811    tier: u8,
1812    parent_path: Option<String>,
1813}
1814
1815#[allow(clippy::too_many_arguments)]
1816fn write_home_page(
1817    output_dir: &Path,
1818    title: &str,
1819    base_url: &str,
1820    wiki_pages: &[WikiPageMeta],
1821    has_changelog: bool,
1822    has_map: bool,
1823    has_onboard: bool,
1824    has_timeline: bool,
1825    has_glossary: bool,
1826    has_explorer: bool,
1827    project_overview: Option<&str>,
1828    onboard_data: Option<&onboard::OnboardData>,
1829    timeline_data: Option<&git_intel::GitIntel>,
1830) -> Result<()> {
1831    // Extract the path component from base_url (e.g., "https://example.com/sub/" -> "/sub/", "/" -> "/")
1832    let base_path = if base_url.contains("://") {
1833        // Full URL: extract path after host
1834        let after_scheme = &base_url[base_url.find("://").unwrap() + 3..];
1835        let path = after_scheme.find('/').map_or("/", |i| &after_scheme[i..]);
1836        if path.ends_with('/') {
1837            path.to_string()
1838        } else {
1839            format!("{path}/")
1840        }
1841    } else if base_url.ends_with('/') {
1842        base_url.to_string()
1843    } else {
1844        format!("{base_url}/")
1845    };
1846    let mut content = String::new();
1847
1848    content.push_str("+++\n");
1849    content.push_str(&format!("title = \"{}\"\n", title));
1850    content.push_str("sort_by = \"weight\"\n");
1851    content.push_str("+++\n\n");
1852
1853    content.push_str(&format!("# {}\n\n", title));
1854
1855    // Project Overview (narrated or structural fallback)
1856    if let Some(overview) = project_overview {
1857        content.push_str(overview);
1858        content.push_str("\n\n");
1859    } else {
1860        content.push_str("Auto-generated codebase documentation powered by [Reflex](https://github.com/reflex-search/reflex).\n\n");
1861    }
1862
1863    // At-a-glance stats cards
1864    if let Some(ob) = onboard_data {
1865        content.push_str("<div class=\"stats-row\">\n");
1866        content.push_str(&format!(
1867            "<div class=\"stat-card\"><div class=\"stat-value\">{}</div><div class=\"stat-label\">Files</div></div>\n",
1868            ob.project_stats.total_files
1869        ));
1870        content.push_str(&format!(
1871            "<div class=\"stat-card\"><div class=\"stat-value\">{}</div><div class=\"stat-label\">Lines</div></div>\n",
1872            ob.project_stats.total_lines
1873        ));
1874        content.push_str(&format!(
1875            "<div class=\"stat-card\"><div class=\"stat-value\">{}</div><div class=\"stat-label\">Modules</div></div>\n",
1876            ob.project_stats.module_count
1877        ));
1878        content.push_str(&format!(
1879            "<div class=\"stat-card\"><div class=\"stat-value\">{}</div><div class=\"stat-label\">Languages</div></div>\n",
1880            ob.project_stats.languages.len()
1881        ));
1882        if let Some(tl) = timeline_data
1883            && !tl.contributors.is_empty()
1884        {
1885            content.push_str(&format!(
1886                    "<div class=\"stat-card\"><div class=\"stat-value\">{}</div><div class=\"stat-label\">Contributors</div></div>\n",
1887                    tl.contributors.len()
1888                ));
1889        }
1890        content.push_str("</div>\n\n");
1891
1892        // Language distribution
1893        if !ob.project_stats.languages.is_empty() {
1894            content.push_str("### Language Distribution\n\n");
1895            content.push_str("| Language | Files |\n|---|---|\n");
1896            for (lang, count) in ob.project_stats.languages.iter().take(8) {
1897                content.push_str(&format!("| {} | {} |\n", lang, count));
1898            }
1899            content.push('\n');
1900        }
1901    }
1902
1903    // Quick links
1904    content.push_str("## Explore\n\n");
1905    content.push_str("<div class=\"quick-links\">\n");
1906    if has_onboard {
1907        content.push_str(&format!("<a href=\"{}onboard/\" class=\"quick-link\"><strong>Onboard</strong><br>Getting started guide</a>\n", base_path));
1908    }
1909    content.push_str(&format!("<a href=\"{}wiki/\" class=\"quick-link\"><strong>Reference</strong><br>Per-module documentation</a>\n", base_path));
1910    if has_glossary {
1911        content.push_str(&format!("<a href=\"{}glossary/\" class=\"quick-link\"><strong>Glossary</strong><br>Domain concepts &amp; vocabulary</a>\n", base_path));
1912    }
1913    if has_changelog {
1914        content.push_str(&format!("<a href=\"{}changelog/\" class=\"quick-link\"><strong>Changelog</strong><br>Recent changes</a>\n", base_path));
1915    }
1916    if has_timeline {
1917        content.push_str(&format!("<a href=\"{}timeline/\" class=\"quick-link\"><strong>Timeline</strong><br>Development activity</a>\n", base_path));
1918    }
1919    if has_map {
1920        content.push_str(&format!("<a href=\"{}map/\" class=\"quick-link\"><strong>Map</strong><br>Dependency graph</a>\n", base_path));
1921    }
1922    if has_explorer {
1923        content.push_str(&format!("<a href=\"{}explorer/\" class=\"quick-link\"><strong>Explorer</strong><br>Visual treemap</a>\n", base_path));
1924    }
1925    content.push_str("</div>\n\n");
1926
1927    // Recent activity summary (from timeline)
1928    if let Some(tl) = timeline_data
1929        && !tl.weekly_summaries.is_empty()
1930    {
1931        content.push_str("## Recent Activity\n\n");
1932        if let Some(week) = tl.weekly_summaries.first() {
1933            content.push_str(&format!(
1934                "Week of {}: **{}** commits across **{}** files by **{}** contributors.\n\n",
1935                week.week_start,
1936                week.commit_count,
1937                week.files_changed,
1938                week.contributors.len()
1939            ));
1940        }
1941        if !tl.churn.is_empty() {
1942            content.push_str("Most active files: ");
1943            let top: Vec<String> = tl
1944                .churn
1945                .iter()
1946                .take(5)
1947                .map(|f| format!("`{}`", f.path))
1948                .collect();
1949            content.push_str(&top.join(", "));
1950            content.push_str("\n\n");
1951        }
1952    }
1953
1954    // Core Modules (Tier 1) as prominent cards
1955    let tier1: Vec<&WikiPageMeta> = wiki_pages.iter().filter(|p| p.tier == 1).collect();
1956    let tier2: Vec<&WikiPageMeta> = wiki_pages.iter().filter(|p| p.tier == 2).collect();
1957
1958    if !tier1.is_empty() {
1959        content.push_str("## Core Modules\n\n");
1960        content.push_str("<div class=\"module-grid\">\n\n");
1961        for page in &tier1 {
1962            let desc = truncate_str(&page.description, 150);
1963            content.push_str(&format!(
1964                "<div class=\"module-card tier-1-card\">\n\
1965                 <h3><a href=\"{}wiki/{}/\">{}</a></h3>\n\
1966                 <div class=\"module-stats\">{} files · {} lines</div>\n\
1967                 <p>{}</p>\n",
1968                base_path, page.slug, page.title, page.file_count, page.total_lines, desc
1969            ));
1970
1971            // Nest Tier 2 children under their Tier 1 parent
1972            let parent_name = page.title.trim_end_matches('/');
1973            let children: Vec<&&WikiPageMeta> = tier2
1974                .iter()
1975                .filter(|t2| t2.parent_path.as_deref() == Some(parent_name))
1976                .collect();
1977            if !children.is_empty() {
1978                content.push_str("<ul class=\"sub-modules\">\n");
1979                for child in children {
1980                    content.push_str(&format!(
1981                        "<li><a href=\"{}wiki/{}/\">{}</a> <span class=\"sub-stats\">({} files)</span></li>\n",
1982                        base_path, child.slug, child.title, child.file_count
1983                    ));
1984                }
1985                content.push_str("</ul>\n");
1986            }
1987
1988            content.push_str("</div>\n\n");
1989        }
1990        content.push_str("</div>\n\n");
1991    }
1992
1993    // Remaining Tier 2 modules that don't have a Tier 1 parent shown above
1994    let orphan_tier2: Vec<&&WikiPageMeta> = tier2
1995        .iter()
1996        .filter(|t2| {
1997            let parent = t2.parent_path.as_deref().unwrap_or("");
1998            !tier1
1999                .iter()
2000                .any(|t1| t1.title.trim_end_matches('/') == parent)
2001        })
2002        .collect();
2003    if !orphan_tier2.is_empty() {
2004        content.push_str("## Sub-modules\n\n");
2005        content.push_str("| Module | Files | Lines | Description |\n|---|---|---|---|\n");
2006        for page in orphan_tier2 {
2007            let desc = truncate_str(&page.description, 77);
2008            content.push_str(&format!(
2009                "| [{}](@/wiki/{}.md) | {} | {} | {} |\n",
2010                page.title, page.slug, page.file_count, page.total_lines, desc
2011            ));
2012        }
2013        content.push('\n');
2014    }
2015
2016    std::fs::write(output_dir.join("content/_index.md"), content)
2017        .context("Failed to write home page")
2018}
2019
2020fn write_wiki_section_index(output_dir: &Path) -> Result<()> {
2021    let content = r#"+++
2022title = "Reference"
2023sort_by = "weight"
2024template = "section.html"
2025
2026[extra]
2027has_search_filter = true
2028+++
2029
2030Per-module documentation pages. Each page covers a detected module's structure,
2031dependencies, key symbols, and metrics.
2032"#;
2033
2034    std::fs::write(output_dir.join("content/wiki/_index.md"), content)
2035        .context("Failed to write wiki section index")
2036}
2037
2038fn write_wiki_page(
2039    output_dir: &Path,
2040    page: &wiki::WikiPage,
2041    module: Option<&&wiki::ModuleDefinition>,
2042    weight: usize,
2043) -> Result<()> {
2044    let slug = page.module_path.replace('/', "-");
2045    let mut content = String::new();
2046
2047    // TOML front matter
2048    content.push_str("+++\n");
2049    content.push_str(&format!("title = \"{}\"\n", page.title));
2050    content.push_str(&format!("weight = {}\n", weight));
2051    if let Some(summary) = &page.sections.summary {
2052        let desc = truncate_str(summary, 200)
2053            .replace('\\', "\\\\")
2054            .replace('"', "'")
2055            .replace('\n', " ");
2056        content.push_str(&format!("description = \"{}\"\n", desc));
2057    }
2058
2059    let has_mermaid = page.sections.dependency_diagram.is_some();
2060
2061    content.push_str("\n[extra]\n");
2062    if let Some(m) = module {
2063        content.push_str(&format!("tier = {}\n", m.tier));
2064        content.push_str(&format!("file_count = {}\n", m.file_count));
2065        content.push_str(&format!("total_lines = {}\n", m.total_lines));
2066        content.push_str(&format!("languages = \"{}\"\n", m.languages.join(", ")));
2067        // Parent path for breadcrumb navigation (Tier 2 modules)
2068        if m.tier == 2
2069            && let Some(parent) = page.module_path.split('/').next()
2070        {
2071            content.push_str(&format!("parent_path = \"{}\"\n", parent));
2072        }
2073    }
2074    if has_mermaid {
2075        content.push_str("has_mermaid = true\n");
2076    }
2077    content.push_str("+++\n\n");
2078
2079    // Page content
2080    if let Some(summary) = &page.sections.summary {
2081        content.push_str(summary);
2082        content.push_str("\n\n");
2083    }
2084
2085    // Dependency diagram (mermaid)
2086    if let Some(diagram) = &page.sections.dependency_diagram {
2087        content.push_str("## Dependency Diagram\n\n");
2088        content.push_str("{% mermaid() %}\n");
2089        content.push_str(diagram);
2090        content.push_str("{% end %}\n\n");
2091    }
2092
2093    content.push_str("## Structure\n\n");
2094    content.push_str(&page.sections.structure);
2095    content.push_str("\n\n");
2096
2097    content.push_str("## Dependencies\n\n");
2098    content.push_str(&page.sections.dependencies);
2099    content.push_str("\n\n");
2100
2101    content.push_str("## Dependents\n\n");
2102    content.push_str(&page.sections.dependents);
2103    content.push_str("\n\n");
2104
2105    if let Some(circular) = &page.sections.circular_deps {
2106        content.push_str("## Circular Dependencies\n\n");
2107        content.push_str(circular);
2108        content.push_str("\n\n");
2109    }
2110
2111    content.push_str("## Key Symbols\n\n");
2112    content.push_str(&page.sections.key_symbols);
2113    content.push_str("\n\n");
2114
2115    content.push_str("## Metrics\n\n");
2116    content.push_str(&page.sections.metrics);
2117    content.push_str("\n\n");
2118
2119    if let Some(changes) = &page.sections.recent_changes {
2120        content.push_str("## Recent Changes\n\n");
2121        content.push_str(changes);
2122        content.push_str("\n\n");
2123    }
2124
2125    let filename = format!("{}.md", slug);
2126    std::fs::write(output_dir.join("content/wiki").join(&filename), content)
2127        .with_context(|| format!("Failed to write wiki page: {}", filename))
2128}
2129
2130fn write_changelog_page(
2131    output_dir: &Path,
2132    changelog_md: &str,
2133    _changelog_data: &changelog::Changelog,
2134) -> Result<()> {
2135    let title = "Changelog";
2136
2137    let mut index_content = String::new();
2138    index_content.push_str("+++\n");
2139    index_content.push_str(&format!("title = \"{}\"\n", title));
2140    index_content.push_str("template = \"section.html\"\n");
2141    index_content.push_str("+++\n\n");
2142    index_content.push_str(changelog_md);
2143
2144    std::fs::write(
2145        output_dir.join("content/changelog/_index.md"),
2146        index_content,
2147    )
2148    .context("Failed to write changelog page")
2149}
2150
2151fn write_map_page(
2152    output_dir: &Path,
2153    mermaid_content: &str,
2154    layered_content: Option<&str>,
2155    narrative: Option<&str>,
2156) -> Result<()> {
2157    let mut content = String::new();
2158    content.push_str("+++\n");
2159    content.push_str("title = \"Architecture Map\"\n");
2160    content.push_str("template = \"section.html\"\n");
2161    content.push_str("\n[extra]\n");
2162    content.push_str("has_mermaid = true\n");
2163    content.push_str("+++\n\n");
2164
2165    // Architecture narrative (LLM-generated or structural fallback)
2166    if let Some(narrative) = narrative {
2167        content.push_str(narrative);
2168        content.push_str("\n\n");
2169    } else {
2170        content.push_str(
2171            "Module-level dependency graph showing how code modules relate to each other.\n\n",
2172        );
2173    }
2174
2175    // Diagram with view toggle (flat vs layered)
2176    content.push_str("## Dependency Graph\n\n");
2177
2178    if let Some(layered) = layered_content {
2179        content.push_str("<div class=\"diagram-tabs\">\n");
2180        content.push_str("  <button class=\"diagram-tab active\" onclick=\"switchDiagram('flat')\">Flat View</button>\n");
2181        content.push_str("  <button class=\"diagram-tab\" onclick=\"switchDiagram('layered')\">Layered View</button>\n");
2182        content.push_str("</div>\n\n");
2183
2184        content.push_str("<div id=\"diagram-flat\" class=\"diagram-panel active\">\n\n");
2185        content.push_str("{% mermaid() %}\n");
2186        content.push_str(mermaid_content);
2187        content.push_str("{% end %}\n\n");
2188        content.push_str("</div>\n\n");
2189
2190        content.push_str("<div id=\"diagram-layered\" class=\"diagram-panel\">\n\n");
2191        content.push_str("{% mermaid() %}\n");
2192        content.push_str(layered);
2193        content.push_str("{% end %}\n\n");
2194        content.push_str("</div>\n\n");
2195
2196        content.push_str("<script>\n");
2197        content.push_str("function switchDiagram(view) {\n");
2198        content.push_str("  document.querySelectorAll('.diagram-panel').forEach(p => p.classList.remove('active'));\n");
2199        content.push_str("  document.querySelectorAll('.diagram-tab').forEach(t => t.classList.remove('active'));\n");
2200        content.push_str("  document.getElementById('diagram-' + view).classList.add('active');\n");
2201        content.push_str("  event.target.classList.add('active');\n");
2202        content.push_str("}\n");
2203        content.push_str("</script>\n\n");
2204    } else {
2205        content.push_str("{% mermaid() %}\n");
2206        content.push_str(mermaid_content);
2207        content.push_str("{% end %}\n\n");
2208    }
2209
2210    // Legend
2211    content.push_str("## Legend\n\n");
2212    content.push_str(
2213        "- **Thick arrows** indicate many file-level dependency edges between modules.\n",
2214    );
2215    content.push_str(
2216        "- **Red-highlighted nodes** are dependency hotspots (imported by many modules).\n",
2217    );
2218    content.push_str("- **Arrow labels** show the number of file-level import edges.\n");
2219    content.push_str("- **Direction** follows the import: A → B means A depends on B.\n");
2220    content.push_str("- **Click** any module node to navigate to its wiki page.\n");
2221
2222    std::fs::write(output_dir.join("content/map/_index.md"), content)
2223        .context("Failed to write map page")
2224}
2225
2226// ── New page writers ─────────────────────────────────────────
2227
2228fn write_onboard_page(
2229    output_dir: &Path,
2230    onboard_md: &str,
2231    data: &onboard::OnboardData,
2232) -> Result<()> {
2233    let has_mermaid = !data.reading_order.layers.is_empty();
2234    let mut content = String::new();
2235    content.push_str("+++\n");
2236    content.push_str("title = \"Getting Started\"\n");
2237    content.push_str("template = \"section.html\"\n");
2238    if has_mermaid {
2239        content.push_str("\n[extra]\nhas_mermaid = true\n");
2240    }
2241    content.push_str("+++\n\n");
2242    content.push_str(onboard_md);
2243
2244    std::fs::write(output_dir.join("content/onboard/_index.md"), content)
2245        .context("Failed to write onboard page")
2246}
2247
2248fn write_timeline_page(
2249    output_dir: &Path,
2250    timeline_md: &str,
2251    data: &git_intel::GitIntel,
2252) -> Result<()> {
2253    let has_mermaid = !data.weekly_summaries.is_empty();
2254    let mut content = String::new();
2255    content.push_str("+++\n");
2256    content.push_str("title = \"Timeline\"\n");
2257    content.push_str("template = \"section.html\"\n");
2258    if has_mermaid {
2259        content.push_str("\n[extra]\nhas_mermaid = true\n");
2260    }
2261    content.push_str("+++\n\n");
2262    content.push_str(timeline_md);
2263
2264    std::fs::write(output_dir.join("content/timeline/_index.md"), content)
2265        .context("Failed to write timeline page")
2266}
2267
2268fn write_glossary_page(output_dir: &Path, glossary_md: &str) -> Result<()> {
2269    let mut content = String::new();
2270    content.push_str("+++\n");
2271    content.push_str("title = \"Glossary\"\n");
2272    content.push_str("template = \"section.html\"\n");
2273    content.push_str("+++\n\n");
2274    content.push_str(glossary_md);
2275
2276    std::fs::write(output_dir.join("content/glossary/_index.md"), content)
2277        .context("Failed to write glossary page")
2278}
2279
2280fn write_explorer_page(output_dir: &Path, explorer_md: &str) -> Result<()> {
2281    let mut content = String::new();
2282    content.push_str("+++\n");
2283    content.push_str("title = \"Explorer\"\n");
2284    content.push_str("template = \"section.html\"\n");
2285    content.push_str("+++\n\n");
2286    content.push_str(explorer_md);
2287
2288    std::fs::write(output_dir.join("content/explorer/_index.md"), content)
2289        .context("Failed to write explorer page")
2290}
2291
2292// ── Context builders for LLM narration ───────────────────────
2293
2294/// Build structural context for the project overview LLM prompt
2295fn build_project_overview_context(cache: &CacheManager, wiki_pages: &[WikiPageMeta]) -> String {
2296    let mut ctx = String::new();
2297
2298    // Aggregate stats
2299    let tier1_count = wiki_pages.iter().filter(|p| p.tier == 1).count();
2300    let tier2_count = wiki_pages.iter().filter(|p| p.tier == 2).count();
2301
2302    // Query database directly for true totals (wiki_pages would double-count nested modules)
2303    let db_path = cache.path().join("meta.db");
2304    if let Ok(conn) = Connection::open(&db_path) {
2305        let total_files: usize = conn
2306            .query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0))
2307            .unwrap_or(0);
2308        let total_lines: usize = conn
2309            .query_row("SELECT COALESCE(SUM(line_count), 0) FROM files", [], |r| {
2310                r.get(0)
2311            })
2312            .unwrap_or(0);
2313
2314        ctx.push_str(&format!(
2315            "Total files: {}\nTotal lines: {}\n",
2316            total_files, total_lines
2317        ));
2318        ctx.push_str(&format!(
2319            "Tier 1 modules: {}\nTier 2 modules: {}\n\n",
2320            tier1_count, tier2_count
2321        ));
2322
2323        // Language distribution
2324        if let Ok(mut stmt) = conn.prepare(
2325            "SELECT COALESCE(language, 'other'), COUNT(*) FROM files GROUP BY language ORDER BY COUNT(*) DESC LIMIT 10"
2326        )
2327            && let Ok(rows) = stmt.query_map([], |row| {
2328                Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
2329            }) {
2330                ctx.push_str("Languages:\n");
2331                for row in rows.flatten() {
2332                    ctx.push_str(&format!("- {}: {} files\n", row.0, row.1));
2333                }
2334                ctx.push('\n');
2335            }
2336
2337        // Dependency stats
2338        if let Ok(edge_count) = conn.query_row::<usize, _, _>(
2339            "SELECT COUNT(*) FROM file_dependencies WHERE resolved_file_id IS NOT NULL",
2340            [],
2341            |row| row.get(0),
2342        ) {
2343            ctx.push_str(&format!("Dependency edges: {}\n", edge_count));
2344        }
2345
2346        // Hotspot count
2347        if let Ok(hotspot_count) = conn.query_row::<usize, _, _>(
2348            "SELECT COUNT(*) FROM (
2349                SELECT resolved_file_id, COUNT(DISTINCT file_id) as c
2350                FROM file_dependencies WHERE resolved_file_id IS NOT NULL
2351                GROUP BY resolved_file_id HAVING c >= 5
2352            )",
2353            [],
2354            |row| row.get(0),
2355        ) {
2356            ctx.push_str(&format!(
2357                "Dependency hotspots (5+ dependents): {}\n",
2358                hotspot_count
2359            ));
2360        }
2361
2362        // Cycle count (mutual deps)
2363        if let Ok(cycle_count) = conn.query_row::<usize, _, _>(
2364            "SELECT COUNT(*) FROM (
2365                SELECT DISTINCT fd1.file_id FROM file_dependencies fd1
2366                JOIN file_dependencies fd2
2367                  ON fd1.file_id = fd2.resolved_file_id
2368                  AND fd1.resolved_file_id = fd2.file_id
2369                WHERE fd1.resolved_file_id IS NOT NULL AND fd2.resolved_file_id IS NOT NULL
2370            )",
2371            [],
2372            |row| row.get(0),
2373        ) {
2374            ctx.push_str(&format!(
2375                "Files in circular dependencies: {}\n",
2376                cycle_count
2377            ));
2378        }
2379    }
2380
2381    ctx.push('\n');
2382
2383    // Tier 1 modules with descriptions
2384    ctx.push_str("Core modules:\n");
2385    for page in wiki_pages.iter().filter(|p| p.tier == 1) {
2386        let desc = truncate_str(&page.description, 120);
2387        ctx.push_str(&format!(
2388            "- {} ({} files, {} lines): {}\n",
2389            page.title, page.file_count, page.total_lines, desc
2390        ));
2391    }
2392
2393    ctx
2394}
2395
2396/// Build structural context for the architecture narrative LLM prompt
2397fn build_architecture_context(cache: &CacheManager, wiki_pages: &[WikiPageMeta]) -> String {
2398    let mut ctx = String::new();
2399
2400    let db_path = cache.path().join("meta.db");
2401    let conn = match Connection::open(&db_path) {
2402        Ok(c) => c,
2403        Err(_) => return "No dependency data available.".to_string(),
2404    };
2405
2406    // Module-to-module edges
2407    ctx.push_str("Module dependency edges:\n");
2408    let modules: Vec<&WikiPageMeta> = wiki_pages.iter().collect();
2409    for source in &modules {
2410        let source_path = source.title.trim_end_matches('/');
2411        let pattern = format!("{}/%", source_path);
2412        if let Ok(mut stmt) = conn.prepare(
2413            "SELECT DISTINCT f2.path
2414             FROM file_dependencies fd
2415             JOIN files f1 ON fd.file_id = f1.id
2416             JOIN files f2 ON fd.resolved_file_id = f2.id
2417             WHERE f1.path LIKE ?1 AND f2.path NOT LIKE ?1",
2418        ) && let Ok(dep_files) = stmt.query_map([&pattern], |row| row.get::<_, String>(0))
2419        {
2420            let dep_files: Vec<String> = dep_files.flatten().collect();
2421            // Map files to modules
2422            let mut target_modules = std::collections::HashMap::new();
2423            for dep_file in &dep_files {
2424                for target in &modules {
2425                    let target_path = target.title.trim_end_matches('/');
2426                    if dep_file.starts_with(&format!("{}/", target_path)) {
2427                        *target_modules
2428                            .entry(target_path.to_string())
2429                            .or_insert(0usize) += 1;
2430                    }
2431                }
2432            }
2433            for (target, count) in &target_modules {
2434                ctx.push_str(&format!(
2435                    "- {} → {} ({} file edges)\n",
2436                    source_path, target, count
2437                ));
2438            }
2439        }
2440    }
2441
2442    ctx.push('\n');
2443
2444    // Top hotspots
2445    ctx.push_str("Dependency hotspots (most-imported files):\n");
2446    if let Ok(mut stmt) = conn.prepare(
2447        "SELECT f.path, COUNT(DISTINCT fd.file_id) as dep_count
2448         FROM file_dependencies fd
2449         JOIN files f ON fd.resolved_file_id = f.id
2450         GROUP BY fd.resolved_file_id
2451         ORDER BY dep_count DESC
2452         LIMIT 10",
2453    ) && let Ok(rows) = stmt.query_map([], |row| {
2454        Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
2455    }) {
2456        for row in rows.flatten() {
2457            ctx.push_str(&format!("- {} ({} dependents)\n", row.0, row.1));
2458        }
2459    }
2460
2461    ctx.push('\n');
2462
2463    // Circular dependencies
2464    if let Ok(cycle_count) = conn.query_row::<usize, _, _>(
2465        "SELECT COUNT(*) FROM (
2466            SELECT DISTINCT fd1.file_id FROM file_dependencies fd1
2467            JOIN file_dependencies fd2
2468              ON fd1.file_id = fd2.resolved_file_id
2469              AND fd1.resolved_file_id = fd2.file_id
2470            WHERE fd1.resolved_file_id IS NOT NULL AND fd2.resolved_file_id IS NOT NULL
2471        )",
2472        [],
2473        |row| row.get(0),
2474    ) {
2475        ctx.push_str(&format!(
2476            "Files involved in circular dependencies: {}\n",
2477            cycle_count
2478        ));
2479    }
2480
2481    ctx
2482}
2483
2484// ── Zola build ───────────────────────────────────────────────
2485
2486fn try_zola_build(output_dir: &Path) -> bool {
2487    match zola::ensure_zola() {
2488        Ok(zola_path) => {
2489            eprintln!("Building site with Zola...");
2490            let public_dir = output_dir.join("public");
2491
2492            // --output-dir is relative to current_dir, so just use "public"
2493            let result = std::process::Command::new(&zola_path)
2494                .current_dir(output_dir)
2495                .arg("build")
2496                .arg("--force")
2497                .arg("--output-dir")
2498                .arg("public")
2499                .output();
2500
2501            match result {
2502                Ok(output) if output.status.success() => {
2503                    // Count HTML files in public/
2504                    let html_count = count_html_files(&public_dir);
2505                    eprintln!(
2506                        "Site built at {}/ ({} pages)",
2507                        public_dir.display(),
2508                        html_count
2509                    );
2510                    true
2511                }
2512                Ok(output) => {
2513                    let stderr = String::from_utf8_lossy(&output.stderr);
2514                    eprintln!("Zola build failed:\n{}", stderr);
2515                    eprintln!(
2516                        "The Zola project was generated at {}/ — you can build manually with:",
2517                        output_dir.display()
2518                    );
2519                    eprintln!("  cd {} && zola build", output_dir.display());
2520                    false
2521                }
2522                Err(e) => {
2523                    eprintln!("Failed to run Zola: {}", e);
2524                    false
2525                }
2526            }
2527        }
2528        Err(e) => {
2529            eprintln!("Could not download Zola: {}", e);
2530            eprintln!(
2531                "The Zola project was generated at {}/ — install Zola and run:",
2532                output_dir.display()
2533            );
2534            eprintln!("  cd {} && zola build", output_dir.display());
2535            eprintln!(
2536                "Install Zola: https://www.getzola.org/documentation/getting-started/installation/"
2537            );
2538            false
2539        }
2540    }
2541}
2542
2543// ── Pagefind build ──────────────────────────────────────────
2544
2545fn try_pagefind_build(output_dir: &Path) -> bool {
2546    match pagefind::ensure_pagefind() {
2547        Ok(pagefind_path) => {
2548            let public_dir = output_dir.join("public");
2549            if !public_dir.exists() {
2550                return false;
2551            }
2552            eprintln!("Building search index with Pagefind...");
2553            // Run pagefind from output_dir so --site "public" resolves correctly
2554            let result = std::process::Command::new(&pagefind_path)
2555                .current_dir(output_dir)
2556                .arg("--site")
2557                .arg("public")
2558                .output();
2559            match result {
2560                Ok(output) if output.status.success() => {
2561                    eprintln!("Search index built.");
2562                    true
2563                }
2564                Ok(output) => {
2565                    let stderr = String::from_utf8_lossy(&output.stderr);
2566                    eprintln!("Pagefind indexing failed: {}", stderr);
2567                    false
2568                }
2569                Err(e) => {
2570                    eprintln!("Failed to run Pagefind: {}", e);
2571                    false
2572                }
2573            }
2574        }
2575        Err(e) => {
2576            eprintln!(
2577                "Could not download Pagefind: {} (search will be unavailable)",
2578                e
2579            );
2580            false
2581        }
2582    }
2583}
2584
2585/// Copy pagefind output from public/ back to static/ so `zola serve` can find it.
2586fn copy_pagefind_to_static(output_dir: &Path) {
2587    let src = output_dir.join("public/pagefind");
2588    let dst = output_dir.join("static/pagefind");
2589    if !src.exists() {
2590        return;
2591    }
2592    // Remove stale copy
2593    let _ = std::fs::remove_dir_all(&dst);
2594    if let Err(e) = copy_dir_recursive(&src, &dst) {
2595        eprintln!("Warning: could not copy pagefind to static/: {e}");
2596    }
2597}
2598
2599fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
2600    std::fs::create_dir_all(dst)?;
2601    for entry in std::fs::read_dir(src)? {
2602        let entry = entry?;
2603        let ty = entry.file_type()?;
2604        let dest_path = dst.join(entry.file_name());
2605        if ty.is_dir() {
2606            copy_dir_recursive(&entry.path(), &dest_path)?;
2607        } else {
2608            std::fs::copy(entry.path(), &dest_path)?;
2609        }
2610    }
2611    Ok(())
2612}
2613
2614fn count_html_files(dir: &Path) -> usize {
2615    if !dir.exists() {
2616        return 0;
2617    }
2618    walkdir::WalkDir::new(dir)
2619        .into_iter()
2620        .filter_map(|e| e.ok())
2621        .filter(|e| {
2622            e.path()
2623                .extension()
2624                .map(|ext| ext == "html")
2625                .unwrap_or(false)
2626        })
2627        .count()
2628}
2629
2630#[cfg(test)]
2631mod tests {
2632    use super::*;
2633    use tempfile::TempDir;
2634
2635    fn test_settings() -> insta::Settings {
2636        let mut s = insta::Settings::clone_current();
2637        s.set_snapshot_path(
2638            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots"),
2639        );
2640        s.set_prepend_module_to_snapshot(false);
2641        s
2642    }
2643
2644    #[test]
2645    fn snapshot_zola_config_all_surfaces() {
2646        let tmp = TempDir::new().unwrap();
2647        write_zola_config(
2648            tmp.path(),
2649            "/",
2650            "Test Codebase",
2651            &[
2652                Surface::Wiki,
2653                Surface::Changelog,
2654                Surface::Map,
2655                Surface::Onboard,
2656                Surface::Timeline,
2657                Surface::Glossary,
2658                Surface::Explorer,
2659            ],
2660        )
2661        .unwrap();
2662        let content = std::fs::read_to_string(tmp.path().join("config.toml")).unwrap();
2663        test_settings().bind(|| {
2664            insta::assert_snapshot!("pulse_site__zola_config_all_surfaces", content);
2665        });
2666    }
2667
2668    #[test]
2669    fn snapshot_base_html_page_structure() {
2670        let tmp = TempDir::new().unwrap();
2671        create_directory_structure(tmp.path()).unwrap();
2672        write_templates(tmp.path()).unwrap();
2673        let content = std::fs::read_to_string(tmp.path().join("templates/base.html")).unwrap();
2674        test_settings().bind(|| {
2675            insta::assert_snapshot!("pulse_site__base_html_page_structure", content);
2676        });
2677    }
2678
2679    #[test]
2680    fn snapshot_base_html_pagefind_integration() {
2681        let tmp = TempDir::new().unwrap();
2682        create_directory_structure(tmp.path()).unwrap();
2683        write_templates(tmp.path()).unwrap();
2684        let base_html = std::fs::read_to_string(tmp.path().join("templates/base.html")).unwrap();
2685        let pagefind_section = base_html
2686            .lines()
2687            .filter(|l| l.contains("pagefind"))
2688            .collect::<Vec<_>>()
2689            .join("\n");
2690        test_settings().bind(|| {
2691            insta::assert_snapshot!(
2692                "pulse_site__base_html_pagefind_integration",
2693                pagefind_section
2694            );
2695        });
2696    }
2697
2698    #[test]
2699    fn snapshot_home_page_navigation_links() {
2700        let tmp = TempDir::new().unwrap();
2701        create_directory_structure(tmp.path()).unwrap();
2702        write_home_page(
2703            tmp.path(),
2704            "My Project",
2705            "/",
2706            &[],
2707            true,
2708            true,
2709            true,
2710            true,
2711            true,
2712            true,
2713            None,
2714            None,
2715            None,
2716        )
2717        .unwrap();
2718        let content = std::fs::read_to_string(tmp.path().join("content/_index.md")).unwrap();
2719        test_settings().bind(|| {
2720            insta::assert_snapshot!("pulse_site__home_page_navigation_links", content);
2721        });
2722    }
2723}