//! Docsify maintenance: fix code-box blank lines and regenerate `_sidebar.md` //! files under `docs/`. use std::collections::BTreeMap; use std::fmt::Write as _; use std::fs; use std::path::{Path, PathBuf}; use mingling::{ Grouped, RenderResult, Routable, macros::{buffer, command, r_println, renderer}, }; use crate::Next; use crate::res::{CargoError, MessagePrinter}; const DOCS_DIR: &str = "./docs"; const SIDEBAR_HEAD: &str = "- [Welcome!](README)\n"; #[command(node = "docsify-refresh")] pub fn docsify_refresh() -> Next { match refresh_all() { Ok(written) => ResultDocsifyRefresh { written }.to_chain(), Err(e) => ErrorDocsifyRefresh(e).to_chain(), } } fn refresh_all() -> Result, String> { let mut written = Vec::new(); written.extend(fix_code_boxes()); written.extend(gen_sidebars()?); Ok(written) } /// Part 1: docsify renders code blocks poorly when the blank lines around /// them are completely empty — replace them with a single space. fn fix_code_boxes() -> Vec { let mut file_count = 0; let mut fixed_count = 0; let mut written = Vec::new(); collect_md_files(Path::new(DOCS_DIR), &mut |path| { if path .file_name() .is_some_and(|n| n.to_string_lossy().to_lowercase() == "_sidebar.md") { return; } let content = fs::read_to_string(path).unwrap_or_default(); if content.is_empty() { return; } let new_content = fix_code_box_empty_lines(&content); if new_content != content { fs::write(path, &new_content).unwrap(); written.push(format!("fixed: {}", path.display())); fixed_count += 1; } file_count += 1; }); written.push(format!("scanned {file_count} files, fixed {fixed_count}")); written } /// Replaces completely empty lines adjacent to fenced code blocks with lines /// containing a single space. fn fix_code_box_empty_lines(content: &str) -> String { let mut result = String::new(); let lines: Vec<&str> = content.lines().collect(); let len = lines.len(); let mut i = 0; while i < len { let line = lines[i]; result.push_str(line); result.push('\n'); i += 1; if !line.trim_start().starts_with("```") { continue; } // In a code block: find the closing fence. let code_start = i; let mut code_end = len; let mut found_end = false; while i < len { let cline = lines[i]; if cline.trim_start().starts_with("```") && !cline.trim().is_empty() { code_end = i; found_end = true; break; } i += 1; } ensure_space_before_code_block(&mut result); for code_line in lines.iter().take(code_end).skip(code_start) { if code_line.is_empty() { result.push(' '); } else { result.push_str(code_line); } result.push('\n'); } if found_end { result.push_str(lines[code_end]); result.push('\n'); i += 1; if i < len && lines[i].trim().is_empty() && lines[i].is_empty() { result.push(' '); result.push('\n'); i += 1; } } } while result.ends_with('\n') { result.pop(); } result.push('\n'); result } /// Turns a trailing `\n\n` before a code block into `\n \n`. fn ensure_space_before_code_block(result: &mut String) { let len = result.len(); if len >= 2 && &result[len - 2..] == "\n\n" { result.insert(len - 1, ' '); } } /// Part 2: find every README.md under `docs/` (each is a site root) and /// regenerate its `_sidebar.md`. fn gen_sidebars() -> Result, String> { let mut written = Vec::new(); for readme_path in find_all_readmes(Path::new(DOCS_DIR)) { let site_root = readme_path .parent() .ok_or_else(|| format!("{} has no parent", readme_path.display()))?; if let Some(content_dir) = find_content_dir(site_root) { let lines = build_sidebar_content(site_root, &content_dir, SIDEBAR_HEAD); let sidebar_path = site_root.join("_sidebar.md"); fs::write(&sidebar_path, lines) .map_err(|e| format!("failed to write {}: {e}", sidebar_path.display()))?; written.push(format!("generated: {}", sidebar_path.display())); } } Ok(written) } /// Recursively finds all README.md files under a directory. fn find_all_readmes(dir: &Path) -> Vec { let mut results = Vec::new(); if let Ok(read_dir) = fs::read_dir(dir) { let mut entries: Vec<_> = read_dir.flatten().collect(); entries.sort_by_key(std::fs::DirEntry::path); for entry in entries { let path = entry.path(); if path.is_dir() { results.extend(find_all_readmes(&path)); } else if path.file_name().is_some_and(|n| n == "README.md") { results.push(path); } } } results } /// The content directory of a site: `pages/` if present, else the first /// subdirectory containing markdown files. fn find_content_dir(site_root: &Path) -> Option { let pages_dir = site_root.join("pages"); if pages_dir.is_dir() { return Some(pages_dir); } if let Ok(read_dir) = fs::read_dir(site_root) { let mut entries: Vec<_> = read_dir.flatten().collect(); entries.sort_by_key(std::fs::DirEntry::path); for entry in entries { let path = entry.path(); if path.is_dir() && has_markdown_files(&path) { return Some(path); } } } None } fn has_markdown_files(dir: &Path) -> bool { if let Ok(read_dir) = fs::read_dir(dir) { for entry in read_dir.flatten() { let path = entry.path(); if path.is_dir() { if has_markdown_files(&path) { return true; } } else if path.extension().is_some_and(|ext| ext == "md") { return true; } } } false } #[derive(Clone)] struct SidebarEntry { title: String, link: String, } /// Builds the sidebar content from the markdown files under `pages_dir`. fn build_sidebar_content(base_dir: &Path, pages_dir: &Path, sidebar_head: &str) -> String { let mut lines = String::from(sidebar_head); let mut root_files: Vec = Vec::new(); let mut sub_dirs: BTreeMap> = BTreeMap::new(); if let Ok(read_dir) = fs::read_dir(pages_dir) { for entry in read_dir.flatten() { let path = entry.path(); if path.is_dir() { let dir_name = entry.file_name().to_string_lossy().into_owned(); let entries = collect_markdown_files(&path, base_dir); if !entries.is_empty() { let display_name = get_directory_display_name(&path, &dir_name); sub_dirs.insert(display_name, entries); } } else if path.extension().is_some_and(|ext| ext == "md") { root_files.push(SidebarEntry { title: extract_title(&path), link: relative_link(&path, base_dir), }); } } } root_files.sort_by(|a, b| natural_cmp(&a.link, &b.link)); for f in &root_files { let _ = writeln!(lines, "* [{}]({})", f.title, f.link); } for (dir_name, entries) in &sub_dirs { let mut sorted_entries = entries.clone(); sorted_entries.sort_by(|a, b| natural_cmp(&a.link, &b.link)); let _ = writeln!(lines, "* {dir_name}"); for f in &sorted_entries { let _ = writeln!(lines, " * [{}]({})", f.title, f.link); } } lines } /// All `.md` files directly under `dir`, as sidebar entries. fn collect_markdown_files(dir: &Path, base_dir: &Path) -> Vec { let mut entries = Vec::new(); if let Ok(read_dir) = fs::read_dir(dir) { for entry in read_dir.flatten() { let path = entry.path(); if path.extension().is_some_and(|ext| ext == "md") { entries.push(SidebarEntry { title: extract_title(&path), link: relative_link(&path, base_dir), }); } } } entries } /// The link of a file relative to `base_dir`, without the `.md` suffix. fn relative_link(path: &Path, base_dir: &Path) -> String { path.strip_prefix(base_dir) .unwrap_or(path) .to_string_lossy() .replace('\\', "/") .strip_suffix(".md") .unwrap_or_default() .to_string() } /// Extracts the title from the first line `

TITLE

`, /// falling back to the file stem. fn extract_title(path: &Path) -> String { let content = fs::read_to_string(path).unwrap_or_default(); if let Some(first_line) = content.lines().next() { let trimmed = first_line.trim(); if let Some(start) = trimmed.find('>') { let after_start = &trimmed[start + 1..]; if let Some(end) = after_start.find('<') { return after_start[..end].to_string(); } } } path.file_stem().map_or_else( || "Untitled".to_string(), |s| s.to_string_lossy().into_owned(), ) } /// Reads a directory's `.name` file to override its sidebar display name. fn get_directory_display_name(dir_path: &Path, fallback: &str) -> String { let name_file = dir_path.join(".name"); if name_file.is_file() { fs::read_to_string(&name_file) .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| fallback.to_string()) } else { fallback.to_string() } } /// Numeric-aware comparison: `1-x` sorts before `10-x`, unnumbered last. fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { extract_leading_number(a) .cmp(&extract_leading_number(b)) .then_with(|| a.cmp(b)) } /// The leading numeric prefix of a link's file stem, `usize::MAX` if absent. fn extract_leading_number(link: &str) -> usize { if let Some(file_stem) = link.rsplit('/').next() && let Some(num_end) = file_stem.find('-') && let Ok(num) = file_stem[..num_end].parse::() { return num; } usize::MAX } /// Recursively collects all `.md` files under a directory. fn collect_md_files(dir: &Path, callback: &mut dyn FnMut(&Path)) { if let Ok(entries) = fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { collect_md_files(&path, callback); } else if path.extension().is_some_and(|ext| ext == "md") { callback(&path); } } } } /// Files written by `docsify-refresh`. #[derive(Grouped)] pub struct ResultDocsifyRefresh { pub written: Vec, } #[derive(Grouped, Default)] pub struct ErrorDocsifyRefresh(pub String); #[renderer(buffer)] pub fn render_docsify_refresh(r: ResultDocsifyRefresh) { for item in r.written { r_println!("{item}"); } } #[renderer] pub fn render_error_docsify_refresh(e: ErrorDocsifyRefresh, error: &CargoError) -> RenderResult { let render_result = RenderResult::new(); error.println(vec![e.0]); render_result }