diff options
Diffstat (limited to 'mingling_ci/src/tools')
| -rw-r--r-- | mingling_ci/src/tools/docsify_refresh.rs | 373 | ||||
| -rw-r--r-- | mingling_ci/src/tools/example_refresh.rs | 279 | ||||
| -rw-r--r-- | mingling_ci/src/tools/features_refresh.rs | 96 |
3 files changed, 748 insertions, 0 deletions
diff --git a/mingling_ci/src/tools/docsify_refresh.rs b/mingling_ci/src/tools/docsify_refresh.rs new file mode 100644 index 0000000..dfb9b11 --- /dev/null +++ b/mingling_ci/src/tools/docsify_refresh.rs @@ -0,0 +1,373 @@ +//! 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<Vec<String>, 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<String> { + 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<Vec<String>, 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<PathBuf> { + 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<PathBuf> { + 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<SidebarEntry> = Vec::new(); + let mut sub_dirs: BTreeMap<String, Vec<SidebarEntry>> = 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<SidebarEntry> { + 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 `<h1 align="center">TITLE</h1>`, +/// 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::<usize>() + { + 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<String>, +} + +#[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 +} diff --git a/mingling_ci/src/tools/example_refresh.rs b/mingling_ci/src/tools/example_refresh.rs new file mode 100644 index 0000000..ca8443c --- /dev/null +++ b/mingling_ci/src/tools/example_refresh.rs @@ -0,0 +1,279 @@ +//! Regenerates the example documentation module and the examples index. + +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +use just_fmt::snake_case; +use just_template::Template; +use mingling::{ + Grouped, RenderResult, Routable, + macros::{buffer, command, r_println, renderer}, +}; +use serde::Serialize; + +use crate::Next; +use crate::res::{CargoError, MessagePrinter}; + +const EXAMPLE_ROOT: &str = "./examples"; +const EXAMPLE_DOCS_OUTPUT: &str = "./mingling/src/example_docs.rs"; +const EXAMPLE_DOCS_TEMPLATE: &str = include_str!("../../../mingling/src/example_docs.rs.tmpl"); +const EXAMPLES_JSON_OUTPUT: &str = "./docs/example-pages/examples.json"; + +#[command(node = "example-refresh")] +pub fn example_refresh() -> Next { + match refresh_all() { + Ok(written) => ResultExampleRefresh { written }.to_chain(), + Err(e) => ErrorExampleRefresh(e).to_chain(), + } +} + +fn refresh_all() -> Result<Vec<String>, String> { + let mut written = Vec::new(); + written.extend(refresh_example_docs()?); + written.extend(sync_examples()?); + Ok(written) +} + +/// Part 1: regenerate `mingling/src/example_docs.rs` from the examples' +/// `src/main.rs` (header `//!` + code) and `Cargo.toml`. +fn refresh_example_docs() -> Result<Vec<String>, String> { + let mut template = Template::from(EXAMPLE_DOCS_TEMPLATE); + + let mut examples = Vec::new(); + let entries = + fs::read_dir(EXAMPLE_ROOT).map_err(|e| format!("failed to read {EXAMPLE_ROOT}: {e}"))?; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + if !name.starts_with("example-") { + continue; + } + examples.push(ExampleContent::read(&name)); + } + examples.sort_by(|a, b| a.name.cmp(&b.name)); + + let mut written = Vec::new(); + for example in examples { + template + .add_impl("examples".to_string()) + .push(HashMap::from([ + ("example_header".to_string(), example.header), + ("example_import".to_string(), example.cargo_toml), + ("example_code".to_string(), example.code), + ("example_name".to_string(), snake_case!(&example.name)), + ])); + written.push(format!("example_docs: {}", example.name)); + } + + let template_str = template.to_string(); + let template_str = template_str + .lines() + .map(str::trim_end) + .collect::<Vec<_>>() + .join("\n") + + "\n"; + fs::write(EXAMPLE_DOCS_OUTPUT, template_str) + .map_err(|e| format!("failed to write {EXAMPLE_DOCS_OUTPUT}: {e}"))?; + written.push(format!("written: {EXAMPLE_DOCS_OUTPUT}")); + Ok(written) +} + +struct ExampleContent { + name: String, + header: String, + code: String, + cargo_toml: String, +} + +impl ExampleContent { + fn read(name: &str) -> Self { + let prefix = |s: &str| { + s.lines() + .map(|line| format!("/// {line}")) + .collect::<Vec<_>>() + .join("\n") + }; + + let (header, code) = read_header_and_code(name); + Self { + name: name.to_string(), + header: prefix(&header), + code: prefix(&code), + cargo_toml: prefix(&read_cargo_toml(name)), + } + } +} + +/// Reads an example's `src/main.rs`, splitting `//!` doc header from code. +fn read_header_and_code(name: &str) -> (String, String) { + let content = fs::read_to_string(Path::new(EXAMPLE_ROOT).join(name).join("src/main.rs")) + .unwrap_or_default(); + let mut lines = content.lines(); + let mut header = String::new(); + let mut code = String::new(); + + for line in lines.by_ref() { + if line.trim_start().starts_with("//!") { + header.push_str(line.trim_start_matches("//!")); + header.push('\n'); + } else { + code.push_str(line); + code.push('\n'); + break; + } + } + for line in lines { + code.push_str(line); + code.push('\n'); + } + + (header.trim().to_string(), code.trim().to_string()) +} + +fn read_cargo_toml(name: &str) -> String { + fs::read_to_string(Path::new(EXAMPLE_ROOT).join(name).join("Cargo.toml")).unwrap_or_default() +} + +/// Part 2: regenerate `docs/example-pages/examples.json` from each example's +/// `page.toml`. +fn sync_examples() -> Result<Vec<String>, String> { + fs::create_dir_all("docs/example-pages") + .map_err(|e| format!("failed to create docs/example-pages: {e}"))?; + + let mut examples = Vec::new(); + let entries = + fs::read_dir(EXAMPLE_ROOT).map_err(|e| format!("failed to read {EXAMPLE_ROOT}: {e}"))?; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let dir_name = entry.file_name().to_string_lossy().into_owned(); + let page_toml = path.join("page.toml"); + if !page_toml.is_file() { + continue; + } + let Ok(content) = fs::read_to_string(&page_toml) else { + continue; + }; + let Ok(table) = content.parse::<toml::Value>() else { + eprintln!("Warning: failed to parse {}", page_toml.display()); + continue; + }; + let Some(example) = table.get("example") else { + continue; + }; + + let get = |key: &str| { + example + .get(key) + .and_then(|v| v.as_str()) + .unwrap_or_default() + }; + let str_vec = |key: &str| { + example + .get(key) + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() + }; + + let id = get("id"); + examples.push(ExampleMeta { + id: if id.is_empty() { + dir_name.clone() + } else { + id.to_string() + }, + name: { + let name = get("name"); + if name.is_empty() { + dir_name.clone() + } else { + name.to_string() + } + }, + icon: { + let icon = get("icon"); + if icon.is_empty() { + "📦".to_string() + } else { + icon.to_string() + } + }, + category: get("category").to_string(), + desc: get("desc").to_string(), + tags: str_vec("tags"), + files: { + let files = str_vec("files"); + if files.is_empty() { + vec!["Cargo.toml".to_string(), "src/main.rs".to_string()] + } else { + files + } + }, + }); + } + + // Basic first, then alphabetical. + examples.sort_by( + |a, b| match (a.id == "example-basic", b.id == "example-basic") { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.id.cmp(&b.id), + }, + ); + + let json = serde_json::to_string_pretty(&examples) + .map_err(|e| format!("failed to serialize examples: {e}"))?; + fs::write(EXAMPLES_JSON_OUTPUT, json) + .map_err(|e| format!("failed to write {EXAMPLES_JSON_OUTPUT}: {e}"))?; + + Ok(vec![format!( + "synced: {} examples -> {EXAMPLES_JSON_OUTPUT}", + examples.len() + )]) +} + +/// One entry of `docs/example-pages/examples.json`. +#[derive(Serialize)] +struct ExampleMeta { + id: String, + name: String, + icon: String, + category: String, + desc: String, + tags: Vec<String>, + files: Vec<String>, +} + +/// Files written by `example-refresh`. +#[derive(Grouped)] +pub struct ResultExampleRefresh { + pub written: Vec<String>, +} + +#[derive(Grouped, Default)] +pub struct ErrorExampleRefresh(pub String); + +#[renderer(buffer)] +pub fn render_example_refresh(r: ResultExampleRefresh) { + for item in r.written { + r_println!("{item}"); + } +} + +#[renderer] +pub fn render_error_example_refresh(e: ErrorExampleRefresh, error: &CargoError) -> RenderResult { + let render_result = RenderResult::new(); + error.println(vec![e.0]); + render_result +} diff --git a/mingling_ci/src/tools/features_refresh.rs b/mingling_ci/src/tools/features_refresh.rs new file mode 100644 index 0000000..87aeead --- /dev/null +++ b/mingling_ci/src/tools/features_refresh.rs @@ -0,0 +1,96 @@ +//! Regenerates `mingling/src/features.rs` from the `[features]` section of +//! `mingling/Cargo.toml`. + +use std::collections::HashMap; +use std::fs; + +use just_fmt::snake_case; +use just_template::Template; +use mingling::{ + Grouped, RenderResult, Routable, + macros::{buffer, command, r_println, renderer}, +}; + +use crate::Next; +use crate::res::{CargoError, MessagePrinter}; + +const CARGO_TOML_PATH: &str = "./mingling/Cargo.toml"; +const OUTPUT_PATH: &str = "./mingling/src/features.rs"; +const TEMPLATE_CONTENT: &str = include_str!("../../../mingling/src/features.rs.tmpl"); + +#[command(node = "features-refresh")] +pub fn features_refresh() -> Next { + match gen_feature_module() { + Ok(written) => ResultFeaturesRefresh { written }.to_chain(), + Err(e) => ErrorFeaturesRefresh(e).to_chain(), + } +} + +fn gen_feature_module() -> Result<Vec<String>, String> { + let features = parse_features()?; + + let mut template = Template::from(TEMPLATE_CONTENT); + let mut written = Vec::new(); + for feat_name in &features { + let feat_const_name = snake_case!(feat_name).to_uppercase(); + template + .add_impl("features".to_string()) + .push(HashMap::from([ + ("feat_name".to_string(), feat_name.clone()), + ("feat_const_name".to_string(), feat_const_name), + ])); + written.push(format!("feature: {feat_name}")); + } + + let template_str = template.to_string(); + let template_str = template_str + .lines() + .map(str::trim_end) + .collect::<Vec<_>>() + .join("\n") + + "\n"; + fs::write(OUTPUT_PATH, template_str) + .map_err(|e| format!("failed to write {OUTPUT_PATH}: {e}"))?; + written.push(format!("written: {OUTPUT_PATH}")); + Ok(written) +} + +/// All feature names from the `[features]` section, sorted. +fn parse_features() -> Result<Vec<String>, String> { + let content = fs::read_to_string(CARGO_TOML_PATH) + .map_err(|e| format!("failed to read {CARGO_TOML_PATH}: {e}"))?; + let table: toml::Value = content + .parse() + .map_err(|e| format!("failed to parse {CARGO_TOML_PATH}: {e}"))?; + let features = table + .get("features") + .and_then(|v| v.as_table()) + .ok_or_else(|| format!("no [features] section in {CARGO_TOML_PATH}"))?; + + let mut names: Vec<String> = features.keys().cloned().collect(); + names.sort(); + Ok(names) +} + +/// Feature names written by `features-refresh`. +#[derive(Grouped)] +pub struct ResultFeaturesRefresh { + pub written: Vec<String>, +} + +#[derive(Grouped, Default)] +pub struct ErrorFeaturesRefresh(pub String); + +#[renderer(buffer)] +pub fn render_features_refresh(r: ResultFeaturesRefresh) { + for item in r.written { + r_println!("{item}"); + } +} + +#[renderer] +pub fn render_error_features_refresh(e: ErrorFeaturesRefresh, error: &CargoError) -> RenderResult { + let render_result = RenderResult::new(); + error.println(vec![e.0]); + render_result +} |
