1use 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
28fn 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#[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 pub concurrency: usize,
51 pub max_depth: u8,
53 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#[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
108pub fn generate_site(cache: &CacheManager, config: &SiteConfig) -> Result<SiteReport> {
115 let overall_start = std::time::Instant::now();
116
117 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 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_directory_structure(&config.output_dir)?;
139
140 write_zola_config(
142 &config.output_dir,
143 &config.base_url,
144 &config.title,
145 &config.surfaces,
146 )?;
147
148 write_templates(&config.output_dir)?;
150
151 write_static_assets(&config.output_dir)?;
153
154 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, ¤t.path, &pulse_config.thresholds).ok()
163 }
164 _ => None,
165 };
166
167 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 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 let structural_start = std::time::Instant::now();
213
214 let discovery_config = wiki::ModuleDiscoveryConfig {
216 max_depth: config.max_depth,
217 min_files: config.min_files,
218 };
219
220 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 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 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 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 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 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 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 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 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 let mut architecture_narrative: Option<String> = None;
353 let mut project_overview: Option<String> = None;
354
355 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(
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 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 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
681fn 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
708fn 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
757fn write_templates(output_dir: &Path) -> Result<()> {
760 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 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 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 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 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
1028fn 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
1803struct 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 let base_path = if base_url.contains("://") {
1833 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 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 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 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 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 & 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 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 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 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 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 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 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 if let Some(summary) = &page.sections.summary {
2081 content.push_str(summary);
2082 content.push_str("\n\n");
2083 }
2084
2085 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 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 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 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
2226fn 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
2292fn build_project_overview_context(cache: &CacheManager, wiki_pages: &[WikiPageMeta]) -> String {
2296 let mut ctx = String::new();
2297
2298 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 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 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 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 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 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 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
2396fn 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 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 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 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 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
2484fn 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 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 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
2543fn 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 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
2585fn 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 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}