use std::path::{Path, PathBuf}; use eyre::{eyre, ContextCompat, Result, WrapErr}; use log::{debug, error, log_enabled, trace, warn}; use pandoc_ast::MutVisitor; use crate::{ filters, utils::{AutoIdentifier, PandocMeta, PandocOutputExt}, }; pub fn do_build(config: &crate::config::Config) -> Result<()> { let summary = Summary::try_from_file(&config.book.summary)?; let source_root = Path::new(&config.book.summary) .parent() .expect("Summary has no parent"); let files = summary.collect_source_files(source_root)?; let build_dir = Path::new(&config.build.build_dir); trace!("Creating build directory: '{}'", build_dir.display()); std::fs::create_dir_all(build_dir).wrap_err_with(|| { format!( "Could not create build directory: '{}'", build_dir.display() ) })?; // Pre-create files so that we know which links to relativize for SourceFile { path, .. } in &files { let output_file = build_dir.join(path.with_extension("html")); let product_dir = build_dir.join(path.parent().expect("Source file has no parent")); trace!("Creating product directory: '{}'", product_dir.display()); std::fs::create_dir_all(&product_dir).wrap_err_with(|| { format!( "Could not create build output directory: '{}'", product_dir.display() ) })?; std::fs::OpenOptions::new() .write(true) .create(true) .open(&output_file) .wrap_err_with(|| { format!("Failed to create output file: '{}'", output_file.display()) })?; } for SourceFile { path, source } in &files { let mut pandoc_command = pandoc::new(); let output_file = build_dir.join(path.with_extension("html")); debug!("Generating file: '{}'", output_file.display()); // To be captured in the filter let config_clone = config.clone(); let source_dir = path .parent() .expect("Source file has no parent") .to_path_buf(); let build_dir_clone = build_dir.to_path_buf(); let summary_clone = summary.source.clone(); pandoc_command .set_input(pandoc::InputKind::Pipe(source.to_json())) .set_input_format(pandoc::InputFormat::Json, vec![]) .set_output(pandoc::OutputKind::File(output_file)) .set_output_format(pandoc::OutputFormat::Html5, vec![]) .add_options(&[pandoc::PandocOption::SelfContained]) .add_filter(move |source| { let level = source_dir .components() .skip_while(|c| matches!(c, std::path::Component::CurDir)) .count(); let mut insert_summary_filter = filters::InsertSummary { level, summary: &summary_clone, }; let mut relativize_urls_filter = filters::RelativizeUrls { config: &config_clone, // TODO: other output formats extension: "html", build_dir: &build_dir_clone, source_dir: &source_dir, }; let mut source = pandoc_ast::Pandoc::from_json(&source); insert_summary_filter.walk_pandoc(&mut source); relativize_urls_filter.walk_pandoc(&mut source); source.to_json() }); if log_enabled!(log::Level::Trace) { pandoc_command.set_show_cmdline(true); } pandoc_command .execute() .wrap_err_with(|| format!("Failed to generate output of: '{}'", path.display()))?; } Ok(()) } // TODO: move that into generated.rs fn generate_source( title: Vec, children: Vec<(PandocMeta, PathBuf)>, level: usize, ) -> Result { // TODO: make that text configurable let mut content = vec![pandoc_ast::Block::Para(vec![pandoc_ast::Inline::Str( "Here are the articles in this section:".to_string(), )])]; for (mut child, file) in children { let title = match child.remove("title") { None => { warn!("Missing title for file: '{}'", file.display()); vec![pandoc_ast::Inline::Str("Untitled page".to_string())] } Some(pandoc_ast::MetaValue::MetaInlines(inlines)) => inlines, Some(pandoc_ast::MetaValue::MetaString(s)) => { vec![pandoc_ast::Inline::Str(s)] } // TODO: check that other values are actually invalid _ => { error!("Invalid value for title"); vec![pandoc_ast::Inline::Str("Untitled page".to_string())] } }; let link_target = std::iter::repeat(std::path::Component::ParentDir) .take(level) .collect::() .join(file); content.push(pandoc_ast::Block::Para(vec![pandoc_ast::Inline::Link( // TODO: attribute to recognize big links? (String::new(), vec![], vec![]), title, ( link_target .to_str() .expect("Filename contains invalid unicode") .to_string(), String::new(), ), )])); } let mut meta = PandocMeta::new(); meta.insert( "title".to_string(), pandoc_ast::MetaValue::MetaInlines(title), ); Ok(pandoc_ast::Pandoc { meta, blocks: content, pandoc_api_version: vec![1, 22], }) } fn list_content(block: &mut pandoc_ast::Block) -> Result<&mut Vec>> { match block { pandoc_ast::Block::OrderedList(_, list) => Ok(list), pandoc_ast::Block::BulletList(list) => Ok(list), _ => Err(eyre!("Expected list in summary, found something else")), } } fn try_into_node_vec(vec: &mut Vec>) -> Result> { vec.iter_mut().map(Node::try_from_vec_block).collect() } // TODO: support separators like these: // --------- #[derive(Debug)] pub struct Summary { source: pandoc_ast::Pandoc, nodes: Vec, } #[derive(Debug)] struct SourceFile { path: PathBuf, source: pandoc_ast::Pandoc, } // TODO: move that into summary.rs impl Summary { fn try_from_file(file: &str) -> Result { debug!("Parsing summary"); let mut pandoc_command = pandoc::new(); pandoc_command .add_input(file) .set_output_format(pandoc::OutputFormat::Json, vec![]) .set_output(pandoc::OutputKind::Pipe); trace!("Launching pandoc command"); if log_enabled!(log::Level::Trace) { pandoc_command.set_show_cmdline(true); } let output = pandoc_command .execute() .wrap_err("Could not execute pandoc")? .buffer(); let document = pandoc_ast::Pandoc::from_json(&output); let summary: Self = document.try_into()?; if summary.has_files_missing( Path::new(file) .parent() .expect("Summary file has no parent"), ) { return Err(eyre!("Files from the summary are missing, aborting")); } Ok(summary) } fn has_files_missing(&self, root: &Path) -> bool { // Do not use `.any()` to prevent short-circuiting, we want to report all missing files self.nodes.iter().fold(false, |acc, node| { let missing = node.has_files_missing(root); acc || missing }) } /// Get a list of source files. /// /// If a file is a generated file, generate it and store it in memory. fn collect_source_files(&self, root: &Path) -> Result> { let mut result = Vec::new(); for node in &self.nodes { node.collect_source_files(&mut result, root, Path::new("."), 0)?; } Ok(result) } } impl TryFrom for Summary { type Error = eyre::Error; fn try_from(mut document: pandoc_ast::Pandoc) -> Result { if document.blocks.len() != 1 { return Err(eyre!("Summary does not contain a single list")); } let root = &mut document.blocks[0]; let list = list_content(root)?; let nodes = list .iter_mut() .map(Node::try_from_vec_block) .collect::>()?; Ok(Summary { source: document, nodes, }) } } #[derive(Debug)] pub enum Node { Provided { file: String, children: Vec, }, Generated { file: String, title: Vec, children: Vec, }, } impl Node { fn children(&self) -> &[Node] { match self { Node::Provided { children, .. } => children, Node::Generated { children, .. } => children, } } fn has_files_missing(&self, root: &Path) -> bool { if let Node::Provided { file, .. } = self { if !root.join(file).exists() { error!("File '{}' specified in summary does not exists", file); return true; } } // Do not use `.any()` to prevent short-circuiting, we want to report all missing files self.children().iter().fold(false, |acc, node| { let missing = node.has_files_missing(root); acc || missing }) } fn collect_source_files( &self, result: &mut Vec, root: &Path, parent: &Path, level: usize, ) -> Result<()> { let new_parent; let children_; let path; let source: Box _>; match self { Node::Provided { file, children } => { trace!("Parsing file: '{}'", file); // TODO: some filters here? not all filters, since we may want to filter generated // files too let mut pandoc_command = pandoc::new(); pandoc_command .add_input(&root.join(file)) .set_output(pandoc::OutputKind::Pipe) .set_output_format(pandoc::OutputFormat::Json, vec![]); if log_enabled!(log::Level::Trace) { pandoc_command.set_show_cmdline(true); } let raw_source = pandoc_command .execute() .wrap_err_with(|| format!("Failed to parse '{}'", file))? .buffer(); source = Box::new(move |_| Ok(pandoc_ast::Pandoc::from_json(&raw_source))); let file = Path::new(&file); let stem = file.file_stem().expect("No file name"); let id = AutoIdentifier::from(stem.to_str().wrap_err("Invalid unicode in file name")?); path = file.into(); new_parent = file.parent().expect("Source file has no parent").join(&*id); children_ = children; } Self::Generated { file, title, children, } => { trace!("Found file to generate: '{}'", file); path = file.into(); source = Box::new(move |direct_children| { generate_source(title.clone(), direct_children, level) }); new_parent = Path::new(file).with_extension(""); children_ = children; } }; let mut direct_children = Vec::with_capacity(children_.len()); for child in children_ { child.collect_source_files(result, root, &new_parent, level + 1)?; let direct_child = result.last().unwrap(); direct_children.push((direct_child.source.meta.clone(), direct_child.path.clone())); } result.push(SourceFile { path, source: source(direct_children)?, }); Ok(()) } // Wil also modify the block to linkify generated pages fn try_from_vec_block(value: &mut Vec) -> Result { if value.len() != 1 && value.len() != 2 { // TODO: better error message? return Err(eyre!("Summary does not contain a single list")); } let mut value = value.iter_mut(); let item = match value.next().unwrap() { pandoc_ast::Block::Plain(inlines) => inlines, pandoc_ast::Block::Para(inlines) => inlines, _ => return Err(eyre!("List item is not a link or plain text")), }; if item.is_empty() { return Err(eyre!("Summary list items cannot be empty")); } let children = if let Some(children) = value.next() { try_into_node_vec(list_content(children)?)? } else { vec![] }; match &item[0] { pandoc_ast::Inline::Link(_, _, target) => { if item.len() != 1 { return Err(eyre!("Summary list item not a single link or plain text")); } let file = target.0.clone(); Ok(Node::Provided { file, children }) } _ => { let title = item.clone(); let id = AutoIdentifier::from(title.as_slice()); // TODO: missing parent // Move generate page into this pass //let mut file = parent.join(&*id); //file.set_extension("md"); // TODO: Attribute to style them differently *item = vec![pandoc_ast::Inline::Link( (String::new(), vec!["generated".to_string()], vec![]), item.clone(), (id.0.clone(), String::new()), )]; Ok(Node::Generated { file: id.0, title, children, }) } } } }