pub mod html;
pub mod plain;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use crate::config::schema::TranscludeConfig;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Format {
Markdown,
Text,
Html,
Pdf,
}
impl Format {
pub fn parse(s: &str) -> Option<Format> {
Some(match s.trim().to_ascii_lowercase().as_str() {
"md" | "markdown" => Format::Markdown,
"txt" | "text" => Format::Text,
"html" | "htm" | "web" => Format::Html,
"pdf" => Format::Pdf,
_ => return None,
})
}
pub fn extension(self) -> &'static str {
match self {
Format::Markdown => "md",
Format::Text => "txt",
Format::Html => "html",
Format::Pdf => "pdf",
}
}
pub fn name(self) -> &'static str {
match self {
Format::Markdown => "markdown",
Format::Text => "plain text",
Format::Html => "HTML",
Format::Pdf => "PDF",
}
}
}
pub fn default_path(source: &Path, format: Format) -> PathBuf {
let stem = source
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "export".into());
source.with_file_name(format!("{stem}.{}", format.extension()))
}
pub fn write(
source: &Path,
dest: &Path,
format: Format,
cfg: &TranscludeConfig,
page: &html::Page,
) -> Result<()> {
if crate::transclude::same_file(dest, source) {
bail!("that would overwrite the document you are exporting");
}
let flat = crate::transclude::compile::compile(source, cfg)?;
if let Some(parent) = dest.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", short(parent)))?;
}
match format {
Format::Markdown => std::fs::write(dest, flat).with_context(|| short(dest))?,
Format::Text => {
std::fs::write(dest, plain::render(&flat, page.measure, page.justify))
.with_context(|| short(dest))?
}
Format::Html => {
let title = source
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "document".into());
std::fs::write(dest, html::render(&flat, &title, page)).with_context(|| short(dest))?
}
Format::Pdf => write_pdf(&flat, dest)?,
}
Ok(())
}
fn short(p: &Path) -> String {
p.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| p.display().to_string())
}
fn write_pdf(markdown: &str, dest: &Path) -> Result<()> {
use std::io::Write;
use std::process::{Command, Stdio};
let Some(tool) = which("pandoc") else {
bail!("needs pandoc on PATH — or export md instead");
};
let Some(engine) = PDF_ENGINES.iter().find(|e| which(e).is_some()) else {
bail!("pandoc has no PDF engine — try: brew install typst");
};
let mut child = Command::new(tool)
.arg("--from=markdown")
.arg(format!("--pdf-engine={engine}"))
.arg("--output")
.arg(dest)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.context("running pandoc")?;
child
.stdin
.as_mut()
.context("pandoc took no input")?
.write_all(markdown.as_bytes())
.context("sending the document to pandoc")?;
let out = child.wait_with_output().context("waiting for pandoc")?;
if !out.status.success() {
let why = String::from_utf8_lossy(&out.stderr);
let why = why.lines().next().unwrap_or("pandoc failed").trim();
bail!("pandoc: {why}");
}
Ok(())
}
const PDF_ENGINES: &[&str] = &[
"typst",
"tectonic",
"xelatex",
"lualatex",
"pdflatex",
"weasyprint",
"wkhtmltopdf",
];
fn which(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|dir| dir.join(name))
.find(|p| p.is_file())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_parse_by_extension_or_name() {
assert_eq!(Format::parse("md"), Some(Format::Markdown));
assert_eq!(Format::parse("markdown"), Some(Format::Markdown));
assert_eq!(Format::parse("TXT"), Some(Format::Text));
assert_eq!(Format::parse("text"), Some(Format::Text));
assert_eq!(Format::parse("pdf"), Some(Format::Pdf));
assert_eq!(Format::parse("html"), Some(Format::Html));
assert_eq!(Format::parse("docx"), None);
}
#[test]
fn the_suggested_path_is_never_the_source() {
let src = Path::new("/tmp/notes.md");
assert_eq!(default_path(src, Format::Text), Path::new("/tmp/notes.txt"));
assert_eq!(default_path(src, Format::Pdf), Path::new("/tmp/notes.pdf"));
assert_eq!(default_path(src, Format::Html), Path::new("/tmp/notes.html"));
let md = default_path(src, Format::Markdown);
assert_eq!(md, Path::new("/tmp/notes.md"));
}
fn vault(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicUsize, Ordering};
static N: AtomicUsize = AtomicUsize::new(0);
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let n = N.fetch_add(1, Ordering::Relaxed);
let d = std::env::temp_dir().join(format!("shoin-ex-{tag}-{t}-{n}"));
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn exporting_markdown_flattens_the_embeds() {
let d = vault("md");
std::fs::write(d.join("frag.md"), "# Frag\n\nfragment body\n").unwrap();
let src = d.join("note.md");
std::fs::write(&src, "# Note\n\n![[frag]]\n").unwrap();
let dest = d.join("out.md");
write(&src, &dest, Format::Markdown, &TranscludeConfig::default(), &html::Page::default()).unwrap();
let got = std::fs::read_to_string(&dest).unwrap();
assert!(got.contains("fragment body"));
assert!(got.contains("## Frag"), "demoted, as the compiled form is");
assert!(!got.contains("![["));
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn exporting_text_strips_the_markup_too() {
let d = vault("txt");
std::fs::write(d.join("frag.md"), "# Frag\n\nwith **bold** in it\n").unwrap();
let src = d.join("note.md");
std::fs::write(&src, "# Note\n\n![[frag]]\n").unwrap();
let dest = d.join("out.txt");
write(&src, &dest, Format::Text, &TranscludeConfig::default(), &html::Page::default()).unwrap();
let got = std::fs::read_to_string(&dest).unwrap();
assert!(got.contains("with bold in it"), "markers gone");
assert!(!got.contains("**"));
assert!(!got.contains("##"), "and headings underlined instead");
assert!(got.contains("----"));
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn justify_reaches_the_txt_and_html_exports() {
let d = vault("justify");
let src = d.join("note.md");
std::fs::write(&src, "aaa bbb ccc ddd\n").unwrap();
let page = html::Page { measure: 10, justify: true, ..html::Page::default() };
let txt = d.join("out.txt");
write(&src, &txt, Format::Text, &TranscludeConfig::default(), &page).unwrap();
let got = std::fs::read_to_string(&txt).unwrap();
assert!(got.contains("aaa bbb"), "stretched to the measure: {got:?}");
let html_dest = d.join("out.html");
write(&src, &html_dest, Format::Html, &TranscludeConfig::default(), &page).unwrap();
let got = std::fs::read_to_string(&html_dest).unwrap();
assert!(got.contains("<main class=\"justify\">"));
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn exporting_html_needs_no_tools_and_flattens_the_embeds() {
let d = vault("html");
std::fs::write(d.join("frag.md"), "# Frag\n\nfragment **body**\n").unwrap();
let src = d.join("note.md");
std::fs::write(&src, "# Note\n\n![[frag]]\n").unwrap();
let dest = d.join("out.html");
write(&src, &dest, Format::Html, &TranscludeConfig::default(), &html::Page::default())
.unwrap();
let got = std::fs::read_to_string(&dest).unwrap();
assert!(got.starts_with("<!doctype html>"));
assert!(got.contains("<title>Note</title>"), "named by its own first heading");
assert!(got.contains("<strong>body</strong>"), "the embed was flattened in");
assert!(!got.contains("![["), "and its link is gone");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn export_makes_the_folder_you_asked_for() {
let d = vault("mkdir");
let src = d.join("note.md");
std::fs::write(&src, "body\n").unwrap();
let dest = d.join("out/deeper/final.txt");
write(&src, &dest, Format::Text, &TranscludeConfig::default(), &html::Page::default()).unwrap();
assert!(dest.is_file(), "the path was created on the way");
assert!(std::fs::read_to_string(&dest).unwrap().contains("body"));
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn pdf_export_either_works_or_says_what_is_missing() {
let d = vault("pdf");
let src = d.join("note.md");
std::fs::write(&src, "# Title\n\nSome prose.\n").unwrap();
let dest = d.join("out.pdf");
let have_pandoc = which("pandoc").is_some();
let have_engine = PDF_ENGINES.iter().any(|e| which(e).is_some());
let result = write(&src, &dest, Format::Pdf, &TranscludeConfig::default(), &html::Page::default());
match (have_pandoc, have_engine) {
(true, true) => {
result.expect("pandoc and an engine are installed");
let bytes = std::fs::read(&dest).unwrap();
assert!(bytes.starts_with(b"%PDF-"), "a real PDF, not an empty file");
assert!(bytes.len() > 1000, "…with something in it");
}
(false, _) => {
let e = result.unwrap_err().to_string();
assert!(e.contains("pandoc"), "name the missing tool: {e}");
assert!(e.len() < 64, "short enough for the status line: {e}");
}
(true, false) => {
let e = result.unwrap_err().to_string();
assert!(e.contains("engine"), "name what is missing: {e}");
assert!(e.contains("typst"), "…and the cheapest fix: {e}");
assert!(e.len() < 64, "short enough for the status line: {e}");
}
}
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn refuses_to_write_over_the_source() {
let d = vault("clobber");
let src = d.join("note.md");
std::fs::write(&src, "# Note\n").unwrap();
let err = write(&src, &src, Format::Markdown, &TranscludeConfig::default(), &html::Page::default())
.unwrap_err()
.to_string();
assert!(err.contains("overwrite"), "got: {err}");
assert_eq!(std::fs::read_to_string(&src).unwrap(), "# Note\n");
std::fs::remove_dir_all(&d).ok();
}
}