use std::path::{Path, PathBuf}; use eyre::{eyre, Result, WrapErr}; use log::{debug, error, log_enabled, trace, warn}; use pandoc_ast::MutVisitor; use crate::{ filters::{self, relativize_summary}, utils::{AutoIdentifier, PandocMeta, PandocOutputExt, PathExt}, }; const CSS: &str = include_str!("../res/style.css"); const HTML_TEMPLATE: &str = include_str!("../res/template.html"); pub fn do_build(config: &crate::config::Config) -> Result<()> { let tmpdir = tempfile::tempdir().wrap_err("Could not create temporary directory")?; debug!( "Created temporary directory at: '{}'", tmpdir.path().display() ); let template_path = tmpdir.path().join("template.html"); trace!("Writing HTML template to: '{}'", template_path.display()); std::fs::write(&template_path, HTML_TEMPLATE) .wrap_err("Could not save HTML template in temporary directory")?; let defaults_path = tmpdir.path().join("defaults.yaml"); debug!("Generating file: '{}'", defaults_path.display()); let defaults = std::fs::File::create(&defaults_path).wrap_err("Could not create defaults.yaml")?; serde_json::to_writer(defaults, &config.pandoc).wrap_err("Could not create defaults.yaml")?; let source_root = Path::new(&config.book.summary) .parent() .expect("Summary has no parent"); let (summary, files) = process_summary(&config.book.summary, 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() ) })?; let style_path = build_dir.join("style.css"); debug!("Generating file: '{}'", style_path.display()); std::fs::write(&style_path, CSS).wrap_err("Could not create style.css")?; // 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 level = source_dir .components() .skip_while(|c| matches!(c, std::path::Component::CurDir)) .count(); let summary = relativize_summary(&summary.source, level); let mut source = source.clone(); source.meta.insert( "summary".to_string(), pandoc_ast::MetaValue::MetaBlocks(summary.blocks), ); let style_path = std::iter::repeat(std::path::Component::ParentDir) .take(level) .collect::() .join("style.css"); 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::Css(style_path.to_string()), pandoc::PandocOption::Defaults(defaults_path.clone()), pandoc::PandocOption::SectionDivs, pandoc::PandocOption::Standalone, pandoc::PandocOption::Template(template_path.clone()), ]) .add_filter(move |source| { 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); 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: &[(&PandocMeta, &Path)], 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 (child, file) in children { let title = match child.get("title").cloned() { 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")), } } // 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 fn process_summary(file: &str, source_root: &Path) -> Result<(Summary, Vec)> { 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); 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 mut document = pandoc_ast::Pandoc::from_json(&output); 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 mut source_files = Vec::new(); for element in list.iter_mut() { process_summary_element(element, Path::new("./"), source_root, &mut source_files)?; } Ok((Summary { source: document }, source_files)) } fn process_summary_element( element: &mut Vec, parent: &Path, source_root: &Path, source_files: &mut Vec, ) -> Result<()> { if element.len() != 1 && element.len() != 2 { // TODO: better error message? return Err(eyre!("Summary element does not contain a single list")); } trace!("Parsing summary element"); let mut value = element.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 child_parent = match &item[0] { pandoc_ast::Inline::Link(_, _, target) => { let file = &target.0; Path::new(&file).with_extension("") } _ => { let title = item.clone(); let id = AutoIdentifier::from(title.as_slice()); parent.join(&id.0) } }; trace!("Summary element is {:?}", child_parent); let previous_source_len = source_files.len(); if let Some(children) = value.next() { for child in list_content(children)? { process_summary_element(child, &child_parent, source_root, source_files)?; } } 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(); source_files.push(parse_file(&file, source_root)?); } _ => { let title = item.clone(); let id = AutoIdentifier::from(title.as_slice()); *item = vec![pandoc_ast::Inline::Link( (String::new(), vec!["generated".to_string()], vec![]), item.clone(), ( parent.join(&id.0).with_extension("html").to_string(), String::new(), ), )]; // TODO: this shows children recursively (and has a bug when in a subdirectory) let children_metadata = source_files[previous_source_len..source_files.len()] .iter() .map(|source| (&source.source.meta, source.path.as_ref())) .collect::>(); let source = generate_source(title, &children_metadata, 0)?; source_files.push(SourceFile { path: child_parent.with_extension("html"), source, }); } } Ok(()) } fn parse_file(file: &str, source_root: &Path) -> Result { 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(&source_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(); let source = pandoc_ast::Pandoc::from_json(&raw_source); Ok(SourceFile { path: file.into(), source, }) }