689 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Rust
		
	
	
	
			
		
		
	
	
			689 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			Rust
		
	
	
	
| use crate::{
 | |
|     config::{Config, Repository},
 | |
|     module::CheckedModule,
 | |
| };
 | |
| use aiken_lang::{
 | |
|     ast::{
 | |
|         DataType, Definition, Function, ModuleConstant, RecordConstructor, Span, TypeAlias,
 | |
|         TypedDefinition,
 | |
|     },
 | |
|     format,
 | |
|     parser::extra::Comment,
 | |
|     tipo::Type,
 | |
| };
 | |
| use askama::Template;
 | |
| use itertools::Itertools;
 | |
| use pulldown_cmark as markdown;
 | |
| use serde::Serialize;
 | |
| use serde_json as json;
 | |
| use std::{
 | |
|     path::{Path, PathBuf},
 | |
|     rc::Rc,
 | |
|     time::{Duration, SystemTime},
 | |
| };
 | |
| 
 | |
| const MAX_COLUMNS: isize = 999;
 | |
| const VERSION: &str = env!("CARGO_PKG_VERSION");
 | |
| 
 | |
| mod link_tree;
 | |
| 
 | |
| #[derive(Debug, PartialEq, Eq, Clone)]
 | |
| pub struct DocFile {
 | |
|     pub path: PathBuf,
 | |
|     pub content: String,
 | |
| }
 | |
| 
 | |
| #[derive(Template)]
 | |
| #[template(path = "module.html")]
 | |
| struct ModuleTemplate<'a> {
 | |
|     aiken_version: &'a str,
 | |
|     breadcrumbs: String,
 | |
|     page_title: &'a str,
 | |
|     module_name: String,
 | |
|     project_name: &'a str,
 | |
|     project_version: &'a str,
 | |
|     modules: &'a [DocLink],
 | |
|     functions: Vec<Interspersed>,
 | |
|     types: Vec<DocType>,
 | |
|     constants: Vec<DocConstant>,
 | |
|     documentation: String,
 | |
|     source: &'a DocLink,
 | |
|     timestamp: String,
 | |
| }
 | |
| 
 | |
| impl<'a> ModuleTemplate<'a> {
 | |
|     pub fn is_current_module(&self, module: &DocLink) -> bool {
 | |
|         match module.path.split(".html").next() {
 | |
|             None => false,
 | |
|             Some(name) => self.module_name == name,
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| #[derive(Template)]
 | |
| #[template(path = "page.html")]
 | |
| struct PageTemplate<'a> {
 | |
|     aiken_version: &'a str,
 | |
|     breadcrumbs: &'a str,
 | |
|     page_title: &'a str,
 | |
|     project_name: &'a str,
 | |
|     project_version: &'a str,
 | |
|     modules: &'a [DocLink],
 | |
|     content: String,
 | |
|     source: &'a DocLink,
 | |
|     timestamp: &'a str,
 | |
| }
 | |
| 
 | |
| impl<'a> PageTemplate<'a> {
 | |
|     pub fn is_current_module(&self, _module: &DocLink) -> bool {
 | |
|         false
 | |
|     }
 | |
| }
 | |
| 
 | |
| #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
 | |
| struct DocLink {
 | |
|     indent: usize,
 | |
|     name: String,
 | |
|     path: String,
 | |
| }
 | |
| 
 | |
| impl DocLink {
 | |
|     pub fn is_empty(&self) -> bool {
 | |
|         self.name.is_empty()
 | |
|     }
 | |
| 
 | |
|     pub fn is_separator(&self) -> bool {
 | |
|         self.path.is_empty()
 | |
|     }
 | |
| }
 | |
| 
 | |
| /// Generate documentation files for a given project.
 | |
| ///
 | |
| /// The documentation is built using template files located at the root of this crate.
 | |
| /// With the documentation, we also build a client-side search index to ease navigation
 | |
| /// across multiple modules.
 | |
| pub fn generate_all(root: &Path, config: &Config, modules: Vec<&CheckedModule>) -> Vec<DocFile> {
 | |
|     let timestamp = new_timestamp();
 | |
|     let modules_links = generate_modules_links(&modules);
 | |
| 
 | |
|     let source = match &config.repository {
 | |
|         None => DocLink {
 | |
|             indent: 0,
 | |
|             name: String::new(),
 | |
|             path: String::new(),
 | |
|         },
 | |
|         Some(Repository {
 | |
|             user,
 | |
|             project,
 | |
|             platform,
 | |
|         }) => DocLink {
 | |
|             indent: 0,
 | |
|             name: format!("{user}/{project}"),
 | |
|             path: format!("https://{platform}.com/{user}/{project}"),
 | |
|         },
 | |
|     };
 | |
| 
 | |
|     let mut output_files: Vec<DocFile> = vec![];
 | |
|     let mut search_indexes: Vec<SearchIndex> = vec![];
 | |
| 
 | |
|     for module in &modules {
 | |
|         if module.skip_doc_generation() {
 | |
|             continue;
 | |
|         }
 | |
| 
 | |
|         let (indexes, file) = generate_module(config, module, &modules_links, &source, ×tamp);
 | |
|         if !indexes.is_empty() {
 | |
|             search_indexes.extend(indexes);
 | |
|             output_files.push(file);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     output_files.extend(generate_static_assets(search_indexes));
 | |
|     output_files.push(generate_readme(
 | |
|         root,
 | |
|         config,
 | |
|         &modules_links,
 | |
|         &source,
 | |
|         ×tamp,
 | |
|     ));
 | |
| 
 | |
|     output_files
 | |
| }
 | |
| 
 | |
| fn generate_module(
 | |
|     config: &Config,
 | |
|     module: &CheckedModule,
 | |
|     modules: &[DocLink],
 | |
|     source: &DocLink,
 | |
|     timestamp: &Duration,
 | |
| ) -> (Vec<SearchIndex>, DocFile) {
 | |
|     let mut search_indexes = vec![];
 | |
| 
 | |
|     // Section headers
 | |
|     let mut section_headers = module
 | |
|         .extra
 | |
|         .comments
 | |
|         .iter()
 | |
|         .filter_map(|span| {
 | |
|             let comment = Comment::from((span, module.code.as_str()))
 | |
|                 .content
 | |
|                 .trim_start();
 | |
|             if comment.starts_with("#") {
 | |
|                 let trimmed = comment.trim_start_matches("#");
 | |
|                 let heading = comment.len() - trimmed.len();
 | |
|                 Some((
 | |
|                     span,
 | |
|                     DocSection {
 | |
|                         heading,
 | |
|                         title: trimmed.trim_start().to_string(),
 | |
|                     },
 | |
|                 ))
 | |
|             } else {
 | |
|                 None
 | |
|             }
 | |
|         })
 | |
|         .collect_vec();
 | |
| 
 | |
|     // Functions
 | |
|     let functions: Vec<(Span, DocFunction)> = module
 | |
|         .ast
 | |
|         .definitions
 | |
|         .iter()
 | |
|         .flat_map(DocFunction::from_definition)
 | |
|         .collect();
 | |
| 
 | |
|     functions.iter().for_each(|(_, function)| {
 | |
|         search_indexes.push(SearchIndex::from_function(module, function))
 | |
|     });
 | |
| 
 | |
|     let no_functions = functions.is_empty();
 | |
| 
 | |
|     let mut functions_and_headers = Vec::new();
 | |
| 
 | |
|     for (span_fn, function) in functions {
 | |
|         let mut to_remove = vec![];
 | |
|         for (ix, (span_h, header)) in section_headers.iter().enumerate() {
 | |
|             if span_h.start < span_fn.start {
 | |
|                 functions_and_headers.push(Interspersed::Section(header.clone()));
 | |
|                 to_remove.push(ix);
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         for ix in to_remove.iter().rev() {
 | |
|             section_headers.remove(*ix);
 | |
|         }
 | |
| 
 | |
|         functions_and_headers.push(Interspersed::Function(function))
 | |
|     }
 | |
| 
 | |
|     // Types
 | |
|     let types: Vec<DocType> = module
 | |
|         .ast
 | |
|         .definitions
 | |
|         .iter()
 | |
|         .flat_map(DocType::from_definition)
 | |
|         .collect();
 | |
|     types
 | |
|         .iter()
 | |
|         .for_each(|type_info| search_indexes.push(SearchIndex::from_type(module, type_info)));
 | |
| 
 | |
|     // Constants
 | |
|     let constants: Vec<DocConstant> = module
 | |
|         .ast
 | |
|         .definitions
 | |
|         .iter()
 | |
|         .flat_map(DocConstant::from_definition)
 | |
|         .collect();
 | |
|     constants
 | |
|         .iter()
 | |
|         .for_each(|constant| search_indexes.push(SearchIndex::from_constant(module, constant)));
 | |
| 
 | |
|     let is_empty = no_functions && types.is_empty() && constants.is_empty();
 | |
| 
 | |
|     // Module
 | |
|     if !is_empty {
 | |
|         search_indexes.push(SearchIndex::from_module(module));
 | |
|     }
 | |
| 
 | |
|     let module = ModuleTemplate {
 | |
|         aiken_version: VERSION,
 | |
|         breadcrumbs: to_breadcrumbs(&module.name),
 | |
|         documentation: render_markdown(&module.ast.docs.iter().join("\n")),
 | |
|         modules,
 | |
|         project_name: &config.name.repo.to_string(),
 | |
|         page_title: &format!("{} - {}", module.name, config.name),
 | |
|         module_name: module.name.clone(),
 | |
|         project_version: &config.version.to_string(),
 | |
|         functions: functions_and_headers,
 | |
|         types,
 | |
|         constants,
 | |
|         source,
 | |
|         timestamp: timestamp.as_secs().to_string(),
 | |
|     };
 | |
| 
 | |
|     (
 | |
|         search_indexes,
 | |
|         DocFile {
 | |
|             path: PathBuf::from(format!("{}.html", module.module_name)),
 | |
|             content: module
 | |
|                 .render()
 | |
|                 .expect("Module documentation template rendering"),
 | |
|         },
 | |
|     )
 | |
| }
 | |
| 
 | |
| fn generate_static_assets(search_indexes: Vec<SearchIndex>) -> Vec<DocFile> {
 | |
|     let mut assets: Vec<DocFile> = vec![];
 | |
| 
 | |
|     assets.push(DocFile {
 | |
|         path: PathBuf::from("favicon.svg"),
 | |
|         content: std::include_str!("../templates/favicon.svg").to_string(),
 | |
|     });
 | |
| 
 | |
|     assets.push(DocFile {
 | |
|         path: PathBuf::from("css/atom-one-light.min.css"),
 | |
|         content: std::include_str!("../templates/css/atom-one-light.min.css").to_string(),
 | |
|     });
 | |
| 
 | |
|     assets.push(DocFile {
 | |
|         path: PathBuf::from("css/atom-one-dark.min.css"),
 | |
|         content: std::include_str!("../templates/css/atom-one-dark.min.css").to_string(),
 | |
|     });
 | |
| 
 | |
|     assets.push(DocFile {
 | |
|         path: PathBuf::from("css/index.css"),
 | |
|         content: std::include_str!("../templates/css/index.css").to_string(),
 | |
|     });
 | |
| 
 | |
|     assets.push(DocFile {
 | |
|         path: PathBuf::from("js/highlight.min.js"),
 | |
|         content: std::include_str!("../templates/js/highlight.min.js").to_string(),
 | |
|     });
 | |
| 
 | |
|     assets.push(DocFile {
 | |
|         path: PathBuf::from("js/highlightjs-aiken.js"),
 | |
|         content: std::include_str!("../templates/js/highlightjs-aiken.js").to_string(),
 | |
|     });
 | |
| 
 | |
|     assets.push(DocFile {
 | |
|         path: PathBuf::from("js/lunr.min.js"),
 | |
|         content: std::include_str!("../templates/js/lunr.min.js").to_string(),
 | |
|     });
 | |
| 
 | |
|     assets.push(DocFile {
 | |
|         path: PathBuf::from("js/index.js"),
 | |
|         content: std::include_str!("../templates/js/index.js").to_string(),
 | |
|     });
 | |
| 
 | |
|     assets.push(DocFile {
 | |
|         path: PathBuf::from("search-data.js"),
 | |
|         content: format!(
 | |
|             "window.Aiken.initSearch({});",
 | |
|             json::to_string(&escape_html_contents(search_indexes))
 | |
|                 .expect("search index serialization")
 | |
|         ),
 | |
|     });
 | |
| 
 | |
|     assets
 | |
| }
 | |
| 
 | |
| fn generate_readme(
 | |
|     root: &Path,
 | |
|     config: &Config,
 | |
|     modules: &[DocLink],
 | |
|     source: &DocLink,
 | |
|     timestamp: &Duration,
 | |
| ) -> DocFile {
 | |
|     let path = PathBuf::from("index.html");
 | |
| 
 | |
|     let content = std::fs::read_to_string(root.join("README.md")).unwrap_or_default();
 | |
| 
 | |
|     let template = PageTemplate {
 | |
|         aiken_version: VERSION,
 | |
|         breadcrumbs: ".",
 | |
|         modules,
 | |
|         project_name: &config.name.repo.to_string(),
 | |
|         page_title: &config.name.to_string(),
 | |
|         project_version: &config.version.to_string(),
 | |
|         content: render_markdown(&content),
 | |
|         source,
 | |
|         timestamp: ×tamp.as_secs().to_string(),
 | |
|     };
 | |
| 
 | |
|     DocFile {
 | |
|         path,
 | |
|         content: template.render().expect("Page template rendering"),
 | |
|     }
 | |
| }
 | |
| 
 | |
| fn generate_modules_links(modules: &[&CheckedModule]) -> Vec<DocLink> {
 | |
|     let non_empty_modules = modules
 | |
|         .iter()
 | |
|         .filter(|module| {
 | |
|             !module.skip_doc_generation()
 | |
|                 && module.ast.definitions.iter().any(|def| {
 | |
|                     matches!(
 | |
|                         def,
 | |
|                         Definition::Fn(Function { public: true, .. })
 | |
|                             | Definition::DataType(DataType { public: true, .. })
 | |
|                             | Definition::TypeAlias(TypeAlias { public: true, .. })
 | |
|                             | Definition::ModuleConstant(ModuleConstant { public: true, .. })
 | |
|                     )
 | |
|                 })
 | |
|         })
 | |
|         .sorted_by(|a, b| a.name.cmp(&b.name))
 | |
|         .collect_vec();
 | |
| 
 | |
|     let mut links = link_tree::LinkTree::default();
 | |
| 
 | |
|     for module in non_empty_modules {
 | |
|         links.insert(module.name.as_str());
 | |
|     }
 | |
| 
 | |
|     links.to_vec()
 | |
| }
 | |
| 
 | |
| #[derive(Serialize, PartialEq, Eq, PartialOrd, Ord, Clone)]
 | |
| struct SearchIndex {
 | |
|     doc: String,
 | |
|     title: String,
 | |
|     content: String,
 | |
|     url: String,
 | |
| }
 | |
| 
 | |
| impl SearchIndex {
 | |
|     fn from_function(module: &CheckedModule, function: &DocFunction) -> Self {
 | |
|         SearchIndex {
 | |
|             doc: module.name.to_string(),
 | |
|             title: function.name.to_string(),
 | |
|             content: format!("{}\n{}", function.signature, function.raw_documentation),
 | |
|             url: format!("{}.html#{}", module.name, function.name),
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     fn from_type(module: &CheckedModule, type_info: &DocType) -> Self {
 | |
|         let constructors = type_info
 | |
|             .constructors
 | |
|             .iter()
 | |
|             .map(|constructor| {
 | |
|                 format!(
 | |
|                     "{}\n{}",
 | |
|                     constructor.definition, constructor.raw_documentation
 | |
|                 )
 | |
|             })
 | |
|             .join("\n");
 | |
| 
 | |
|         SearchIndex {
 | |
|             doc: module.name.to_string(),
 | |
|             title: type_info.name.to_string(),
 | |
|             content: format!(
 | |
|                 "{}\n{}\n{}",
 | |
|                 type_info.definition, type_info.raw_documentation, constructors,
 | |
|             ),
 | |
|             url: format!("{}.html#{}", module.name, type_info.name),
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     fn from_constant(module: &CheckedModule, constant: &DocConstant) -> Self {
 | |
|         SearchIndex {
 | |
|             doc: module.name.to_string(),
 | |
|             title: constant.name.to_string(),
 | |
|             content: format!("{}\n{}", constant.definition, constant.raw_documentation),
 | |
|             url: format!("{}.html#{}", module.name, constant.name),
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     fn from_module(module: &CheckedModule) -> Self {
 | |
|         SearchIndex {
 | |
|             doc: module.name.to_string(),
 | |
|             title: module.name.to_string(),
 | |
|             content: module.ast.docs.iter().join("\n"),
 | |
|             url: format!("{}.html", module.name),
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| #[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
 | |
| enum Interspersed {
 | |
|     Section(DocSection),
 | |
|     Function(DocFunction),
 | |
| }
 | |
| 
 | |
| #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
 | |
| struct DocSection {
 | |
|     heading: usize,
 | |
|     title: String,
 | |
| }
 | |
| 
 | |
| #[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
 | |
| struct DocFunction {
 | |
|     name: String,
 | |
|     signature: String,
 | |
|     documentation: String,
 | |
|     raw_documentation: String,
 | |
|     source_url: String,
 | |
| }
 | |
| 
 | |
| impl DocFunction {
 | |
|     fn from_definition(def: &TypedDefinition) -> Option<(Span, Self)> {
 | |
|         match def {
 | |
|             Definition::Fn(func_def) if func_def.public => Some((
 | |
|                 func_def.location,
 | |
|                 DocFunction {
 | |
|                     name: func_def.name.clone(),
 | |
|                     documentation: func_def
 | |
|                         .doc
 | |
|                         .as_deref()
 | |
|                         .map(render_markdown)
 | |
|                         .unwrap_or_default(),
 | |
|                     raw_documentation: func_def.doc.as_deref().unwrap_or_default().to_string(),
 | |
|                     signature: format::Formatter::new()
 | |
|                         .docs_fn_signature(
 | |
|                             &func_def.name,
 | |
|                             &func_def.arguments,
 | |
|                             &func_def.return_annotation,
 | |
|                             func_def.return_type.clone(),
 | |
|                         )
 | |
|                         .to_pretty_string(MAX_COLUMNS),
 | |
|                     source_url: "#todo".to_string(),
 | |
|                 },
 | |
|             )),
 | |
|             _ => None,
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| #[derive(PartialEq, Eq, PartialOrd, Ord)]
 | |
| struct DocConstant {
 | |
|     name: String,
 | |
|     definition: String,
 | |
|     documentation: String,
 | |
|     raw_documentation: String,
 | |
|     source_url: String,
 | |
| }
 | |
| 
 | |
| impl DocConstant {
 | |
|     fn from_definition(def: &TypedDefinition) -> Option<Self> {
 | |
|         match def {
 | |
|             Definition::ModuleConstant(const_def) if const_def.public => Some(DocConstant {
 | |
|                 name: const_def.name.clone(),
 | |
|                 documentation: const_def
 | |
|                     .doc
 | |
|                     .as_deref()
 | |
|                     .map(render_markdown)
 | |
|                     .unwrap_or_default(),
 | |
|                 raw_documentation: const_def.doc.as_deref().unwrap_or_default().to_string(),
 | |
|                 definition: format::Formatter::new()
 | |
|                     .docs_const_expr(&const_def.name, &const_def.value)
 | |
|                     .to_pretty_string(MAX_COLUMNS),
 | |
|                 source_url: "#todo".to_string(),
 | |
|             }),
 | |
|             _ => None,
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| #[derive(PartialEq, Eq, PartialOrd, Ord, Debug)]
 | |
| struct DocType {
 | |
|     name: String,
 | |
|     definition: String,
 | |
|     documentation: String,
 | |
|     raw_documentation: String,
 | |
|     constructors: Vec<DocTypeConstructor>,
 | |
|     parameters: Vec<String>,
 | |
|     opaque: bool,
 | |
|     source_url: String,
 | |
| }
 | |
| 
 | |
| impl DocType {
 | |
|     fn from_definition(def: &TypedDefinition) -> Option<Self> {
 | |
|         match def {
 | |
|             Definition::TypeAlias(info) if info.public => Some(DocType {
 | |
|                 name: info.alias.clone(),
 | |
|                 definition: format::Formatter::new()
 | |
|                     .docs_type_alias(&info.alias, &info.parameters, &info.annotation)
 | |
|                     .to_pretty_string(MAX_COLUMNS),
 | |
|                 documentation: info.doc.as_deref().map(render_markdown).unwrap_or_default(),
 | |
|                 raw_documentation: info.doc.as_deref().unwrap_or_default().to_string(),
 | |
|                 constructors: vec![],
 | |
|                 parameters: info.parameters.clone(),
 | |
|                 opaque: false,
 | |
|                 source_url: "#todo".to_string(),
 | |
|             }),
 | |
| 
 | |
|             Definition::DataType(info) if info.public && !info.opaque => Some(DocType {
 | |
|                 name: info.name.clone(),
 | |
|                 definition: format::Formatter::new()
 | |
|                     .docs_data_type(
 | |
|                         &info.name,
 | |
|                         &info.parameters,
 | |
|                         &info.constructors,
 | |
|                         &info.location,
 | |
|                     )
 | |
|                     .to_pretty_string(MAX_COLUMNS),
 | |
|                 documentation: info.doc.as_deref().map(render_markdown).unwrap_or_default(),
 | |
|                 raw_documentation: info.doc.as_deref().unwrap_or_default().to_string(),
 | |
|                 constructors: info
 | |
|                     .constructors
 | |
|                     .iter()
 | |
|                     .map(DocTypeConstructor::from_record_constructor)
 | |
|                     .collect(),
 | |
|                 parameters: info.parameters.clone(),
 | |
|                 opaque: info.opaque,
 | |
|                 source_url: "#todo".to_string(),
 | |
|             }),
 | |
| 
 | |
|             Definition::DataType(info) if info.public && info.opaque => Some(DocType {
 | |
|                 name: info.name.clone(),
 | |
|                 definition: format::Formatter::new()
 | |
|                     .docs_opaque_data_type(&info.name, &info.parameters, &info.location)
 | |
|                     .to_pretty_string(MAX_COLUMNS),
 | |
|                 documentation: info.doc.as_deref().map(render_markdown).unwrap_or_default(),
 | |
|                 raw_documentation: info.doc.as_deref().unwrap_or_default().to_string(),
 | |
|                 constructors: vec![],
 | |
|                 parameters: info.parameters.clone(),
 | |
|                 opaque: info.opaque,
 | |
|                 source_url: "#todo".to_string(),
 | |
|             }),
 | |
| 
 | |
|             _ => None,
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| #[derive(PartialEq, Eq, PartialOrd, Ord, Debug)]
 | |
| struct DocTypeConstructor {
 | |
|     definition: String,
 | |
|     documentation: String,
 | |
|     raw_documentation: String,
 | |
| }
 | |
| 
 | |
| impl DocTypeConstructor {
 | |
|     fn from_record_constructor(constructor: &RecordConstructor<Rc<Type>>) -> Self {
 | |
|         let doc_args = constructor
 | |
|             .arguments
 | |
|             .iter()
 | |
|             .filter_map(|arg| match (arg.label.as_deref(), arg.doc.as_deref()) {
 | |
|                 (Some(label), Some(doc)) => Some(format!("#### `.{label}`\n{doc}\n<hr/>\n",)),
 | |
|                 _ => None,
 | |
|             })
 | |
|             .join("\n");
 | |
| 
 | |
|         DocTypeConstructor {
 | |
|             definition: format::Formatter::new()
 | |
|                 .docs_record_constructor(constructor)
 | |
|                 .to_pretty_string(format::MAX_COLUMNS),
 | |
|             documentation: constructor
 | |
|                 .doc
 | |
|                 .as_deref()
 | |
|                 .map(|doc| render_markdown(&format!("{doc}\n{doc_args}")))
 | |
|                 .unwrap_or_default(),
 | |
|             raw_documentation: constructor.doc.as_deref().unwrap_or_default().to_string(),
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| // ------ Extra Helpers
 | |
| 
 | |
| fn render_markdown(text: &str) -> String {
 | |
|     let mut s = String::with_capacity(text.len() * 3 / 2);
 | |
|     let p = markdown::Parser::new_ext(text, markdown::Options::all());
 | |
|     markdown::html::push_html(&mut s, p);
 | |
|     s
 | |
| }
 | |
| 
 | |
| fn escape_html_contents(indexes: Vec<SearchIndex>) -> Vec<SearchIndex> {
 | |
|     fn escape_html_content(it: String) -> String {
 | |
|         it.replace('&', "&")
 | |
|             .replace('<', "<")
 | |
|             .replace('>', ">")
 | |
|             .replace('\"', """)
 | |
|             .replace('\'', "'")
 | |
|     }
 | |
| 
 | |
|     indexes
 | |
|         .into_iter()
 | |
|         .map(|idx| SearchIndex {
 | |
|             doc: idx.doc,
 | |
|             title: idx.title,
 | |
|             content: escape_html_content(idx.content),
 | |
|             url: idx.url,
 | |
|         })
 | |
|         .collect::<Vec<SearchIndex>>()
 | |
| }
 | |
| 
 | |
| fn new_timestamp() -> Duration {
 | |
|     SystemTime::now()
 | |
|         .duration_since(SystemTime::UNIX_EPOCH)
 | |
|         .expect("get current timestamp")
 | |
| }
 | |
| 
 | |
| fn to_breadcrumbs(path: &str) -> String {
 | |
|     let breadcrumbs = path
 | |
|         .strip_prefix('/')
 | |
|         .unwrap_or(path)
 | |
|         .split('/')
 | |
|         .skip(1)
 | |
|         .map(|_| "..")
 | |
|         .join("/");
 | |
|     if breadcrumbs.is_empty() {
 | |
|         ".".to_string()
 | |
|     } else {
 | |
|         breadcrumbs
 | |
|     }
 | |
| }
 | |
| 
 | |
| #[test]
 | |
| fn to_breadcrumbs_test() {
 | |
|     // Pages
 | |
|     assert_eq!(to_breadcrumbs("a.html"), ".");
 | |
|     assert_eq!(to_breadcrumbs("/a.html"), ".");
 | |
|     assert_eq!(to_breadcrumbs("/a/b.html"), "..");
 | |
|     assert_eq!(to_breadcrumbs("/a/b/c.html"), "../..");
 | |
| 
 | |
|     // Modules
 | |
|     assert_eq!(to_breadcrumbs("a"), ".");
 | |
|     assert_eq!(to_breadcrumbs("a/b"), "..");
 | |
|     assert_eq!(to_breadcrumbs("a/b/c"), "../..");
 | |
| }
 |