diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73b9c10 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +target/ +dist/ diff --git a/Makefile b/Makefile index ce92a15..12c9c38 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,10 @@ SOURCES := $(shell find src -type f -name '*.md') TARGETS := $(patsubst src/%.md,docs/%.html,$(SOURCES)) +CARGO ?= cargo +TARGET ?= x86_64-unknown-linux-musl +CARGO_TARGET_DIR ?= $(CURDIR)/target + .PHONY: all all: docs/.nojekyll $(TARGETS) @@ -38,4 +42,20 @@ docs: docs/.nojekyll docs/%.html: src/%.md template.html5 Makefile tools/build.sh tools/build.sh "$<" "$@" +### Makefile commands for rust wrapper + +# Build rust wrapper +.PHONY: pandoc-pretty +pandoc-pretty: dist/pandoc-pretty + +dist/pandoc-pretty: pandoc-pretty/src/main.rs pandoc-pretty/Cargo.toml template.html5 docs/css/theme.css docs/css/skylighting-solarized-theme.css pandoc-sidenote.lua + CARGO_TARGET_DIR=$(CARGO_TARGET_DIR) $(CARGO) build --release --target $(TARGET) --manifest-path pandoc-pretty/Cargo.toml + mkdir -p dist + cp $(CARGO_TARGET_DIR)/$(TARGET)/release/pandoc-pretty dist/pandoc-pretty + +# run tests for rust wrapper +.PHONY: test-pandoc-pretty +test-pandoc-pretty: + CARGO_TARGET_DIR=$(CARGO_TARGET_DIR) $(CARGO) test --manifest-path pandoc-pretty/Cargo.toml --tests +### End Makefile commands for rust wrapper diff --git a/README.md b/README.md index 6aef7d1..a7779cc 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,49 @@ make watch More instructions in the [Usage][Usage] section of the website above. +## Native helper binary + +You can optionally build a static helper that wraps your local `pandoc` with this +repo’s template, CSS, and sidenote filter baked in. This allows you to trivially +convert markdown files to html anywhere on your system with a single command, such as: + +```bash +pandoc-pretty README.md +``` + +Which will spit out a README.html file you can open in your browser or deploy to the web. + +You can optionally specify output location and name: + +```bash +pandoc-pretty README.md ~/README-weird-name.html +``` + +### Building the native wrapper + +This binary is a small rust-based wrapper that requires `pandoc` to be installed and +available in your `PATH` variable. Install and init `rustup`, then: + +```bash +rustup target add x86_64-unknown-linux-musl # once +make pandoc-pretty # produces dist/pandoc-pretty +``` + +You may wish to copy the binary somewhere on you system in `PATH`. For example: + +```bash +# If you have ~/bin/ +cp ./dist/pandoc-pretty ~/bin/ + +# Alternatively +sudo cp ./dist/pandoc-pretty /usr/local/bin/ +``` + +Usage: `pandoc-pretty input.md [output.html]` (requires `pandoc` in `PATH`). The +output defaults to the input filename with `.html` extension. The +output HTML is self-contained: template, CSS, and sidenote Lua filter are +embedded so the file works standalone. + [Usage]: https://jez.io/pandoc-markdown-css-theme/#usage ## License diff --git a/pandoc-pretty/Cargo.lock b/pandoc-pretty/Cargo.lock new file mode 100644 index 0000000..63b816c --- /dev/null +++ b/pandoc-pretty/Cargo.lock @@ -0,0 +1,353 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "assert_cmd" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcbb6924530aa9e0432442af08bbcafdad182db80d2e560da42a6d442535bf85" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "pandoc-pretty" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "predicates", + "tempfile", +] + +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" diff --git a/pandoc-pretty/Cargo.toml b/pandoc-pretty/Cargo.toml new file mode 100644 index 0000000..84414df --- /dev/null +++ b/pandoc-pretty/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "pandoc-pretty" +version = "0.1.0" +edition = "2021" +license = "BlueOak-1.0.0" +description = "Wrapper that runs pandoc with bundled template, CSS, and sidenote filter." + +[profile.release] +lto = true +codegen-units = 1 +strip = true + +[dev-dependencies] +assert_cmd = "2.0" +predicates = "3.0" +tempfile = "3.10" diff --git a/pandoc-pretty/src/main.rs b/pandoc-pretty/src/main.rs new file mode 100644 index 0000000..b222202 --- /dev/null +++ b/pandoc-pretty/src/main.rs @@ -0,0 +1,233 @@ +use std::env; +use std::fs::{self, File}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::process::{self, Command}; + +const TEMPLATE_HTML: &str = include_str!("../../template.html5"); +const THEME_CSS: &str = include_str!("../../docs/css/theme.css"); +const SKYLIGHTING_CSS: &str = include_str!("../../docs/css/skylighting-solarized-theme.css"); +const SIDENOTE_LUA: &str = include_str!("../../pandoc-sidenote.lua"); +const DEFAULT_KATEX: &str = "https://cdn.jsdelivr.net/npm/katex@0.15.1/dist/"; + +fn write_file(path: &Path, contents: &str) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut file = File::create(path)?; + file.write_all(contents.as_bytes()) +} + +fn usage(bin: &str) { + eprintln!("usage: {bin} [output.html]"); +} + +fn temp_root() -> io::Result { + let mut dir = env::temp_dir(); + dir.push(format!("pandoc-pretty-{}", process::id())); + fs::create_dir_all(&dir)?; + Ok(dir) +} + +fn katex_url() -> String { + let mut url = env::var("PANDOC_PRETTY_KATEX").unwrap_or_else(|_| DEFAULT_KATEX.to_string()); + if !url.ends_with('/') { + url.push('/'); + } + url +} + +fn has_title_metadata(path: &Path) -> bool { + let Ok(content) = fs::read_to_string(path) else { + return false; + }; + + // YAML metadata block + if content.starts_with("---\n") { + if let Some(end) = content[4..].find("\n---") { + let header = &content[4..4 + end]; + if header + .lines() + .any(|l| l.trim_start().to_ascii_lowercase().starts_with("title:")) + { + return true; + } + } + } + + // Pandoc %-style metadata + if let Some(first_line) = content.lines().next() { + if first_line.starts_with('%') && first_line.len() > 1 { + return true; + } + } + + false +} + +fn ensure_pandoc(bin: &str) { + match Command::new("pandoc") + .arg("--version") + .stdout(process::Stdio::null()) + .stderr(process::Stdio::null()) + .status() + { + Ok(status) if status.success() => {} + Ok(_) | Err(_) => { + eprintln!( + "{bin}: pandoc not found. Please install pandoc and ensure it is on your PATH." + ); + process::exit(127); + } + } +} + +fn confirm_overwrite(path: &Path, bin: &str) { + if path.exists() { + eprintln!( + "{bin}: warning: output file already exists: {}", + path.display() + ); + eprint!("Overwrite? [y/N]: "); + let _ = io::stderr().flush(); + + let mut response = String::new(); + match io::stdin().read_line(&mut response) { + Ok(_) => { + let normalized = response.trim().to_ascii_lowercase(); + if normalized != "y" && normalized != "yes" { + eprintln!("{bin}: aborting; not overwriting existing file"); + process::exit(1); + } + } + Err(err) => { + eprintln!("{bin}: failed to read confirmation: {err}"); + process::exit(1); + } + } + } +} + +fn main() { + let mut args = env::args(); + let bin = args.next().unwrap_or_else(|| "pandoc-pretty".into()); + let input = args.next(); + let mut output = args.next(); + + if input.is_none() { + usage(&bin); + process::exit(64); + } + + let input = input.unwrap(); + let input_path = PathBuf::from(&input); + + ensure_pandoc(&bin); + + if output.is_none() { + let mut derived = input_path.clone(); + if derived.extension().is_some() { + derived.set_extension("html"); + } else { + derived.set_extension("html"); + } + output = Some( + derived + .to_str() + .unwrap_or_else(|| { + eprintln!("pandoc-pretty: could not derive output path from input"); + process::exit(64); + }) + .to_string(), + ); + } + + let output = output.unwrap(); + let output_path = PathBuf::from(&output); + + confirm_overwrite(&output_path, &bin); + + let temp = match temp_root() { + Ok(dir) => dir, + Err(err) => { + eprintln!("pandoc-pretty: failed to create temp dir: {err}"); + process::exit(1); + } + }; + + let template_path = temp.join("template.html5"); + let lua_path = temp.join("pandoc-sidenote.lua"); + let css_dir = temp.join("css"); + let theme_path = css_dir.join("theme.css"); + let skylighting_path = css_dir.join("skylighting-solarized-theme.css"); + + let writes = [ + (template_path.as_path(), TEMPLATE_HTML), + (lua_path.as_path(), SIDENOTE_LUA), + (theme_path.as_path(), THEME_CSS), + (skylighting_path.as_path(), SKYLIGHTING_CSS), + ]; + + for (path, contents) in writes { + if let Err(err) = write_file(path, contents) { + eprintln!("pandoc-pretty: failed to write {path:?}: {err}"); + cleanup(&temp); + process::exit(1); + } + } + + let mut cmd = Command::new("pandoc"); + cmd.arg(format!("--katex={}", katex_url())) + .arg("--from") + .arg("markdown+tex_math_single_backslash") + .arg("--embed-resources") + .arg("--lua-filter") + .arg(&lua_path) + .arg("--to") + .arg("html5+smart") + .arg("--standalone"); + + if !has_title_metadata(&input_path) { + let fallback_title = input_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Document"); + cmd.arg("--metadata").arg(format!("title={fallback_title}")); + } + + cmd.arg("--template") + .arg(&template_path) + .arg("--css") + .arg(&theme_path) + .arg("--css") + .arg(&skylighting_path) + .arg("--toc") + .arg("--wrap=none") + .arg("--output") + .arg(&output_path) + .arg(&input); + + let status = cmd.status(); + + cleanup(&temp); + + match status { + Ok(code) if code.success() => {} + Ok(code) => { + let code = code.code().unwrap_or(-1); + eprintln!("pandoc-pretty: pandoc failed with exit code {code}"); + process::exit(code); + } + Err(err) => { + eprintln!("pandoc-pretty: failed to spawn pandoc: {err}"); + process::exit(127); + } + } +} + +fn cleanup(temp: &Path) { + if let Err(err) = fs::remove_dir_all(temp) { + // Not fatal; leave directory behind for inspection. + eprintln!("pandoc-pretty: warning: unable to remove temp dir {temp:?}: {err}"); + } +} diff --git a/pandoc-pretty/tests/fixtures/full.md b/pandoc-pretty/tests/fixtures/full.md new file mode 100644 index 0000000..d57aada --- /dev/null +++ b/pandoc-pretty/tests/fixtures/full.md @@ -0,0 +1,55 @@ +--- +title: Full Feature Doc +author: Test Author +date: 2025-12-23 +--- + +# Heading One + +This paragraph includes inline math $a^2 + b^2 = c^2$, bold **text**, italic +_text_, inline code `printf("hi")`, a link to [example.com], and a sidenote.[^sn] + +## Heading Two + +- Bullet list item +- Another item with a nested list + - child item + - child item 2 +- [ ] task unchecked +- [x] task checked + +1. Ordered item one +2. Ordered item two + +> Blockquote with a footnote reference.[^foot] + +```python +def hello(): + return "world" +``` + +```rust +fn main() { + println!("hello"); +} +``` + +### Math Block + +$$ +E = mc^2 +$$ + +### Table + +| Animal | Legs | Note | +|:-------|-----:|:------------| +| Cat | 4 | small | +| Ant | 6 | very small | + +--- + +[^sn]: {-} This is a margin note rendered by the sidenote filter. +[^foot]: Footnote content with *emphasis*. + +[example.com]: https://example.com diff --git a/pandoc-pretty/tests/fixtures/katex/katex.min.css b/pandoc-pretty/tests/fixtures/katex/katex.min.css new file mode 100644 index 0000000..85bf34f --- /dev/null +++ b/pandoc-pretty/tests/fixtures/katex/katex.min.css @@ -0,0 +1 @@ +/* minimal katex css stub for tests */ diff --git a/pandoc-pretty/tests/fixtures/katex/katex.min.js b/pandoc-pretty/tests/fixtures/katex/katex.min.js new file mode 100644 index 0000000..6c40562 --- /dev/null +++ b/pandoc-pretty/tests/fixtures/katex/katex.min.js @@ -0,0 +1 @@ +// minimal katex stub for tests diff --git a/pandoc-pretty/tests/integration.rs b/pandoc-pretty/tests/integration.rs new file mode 100644 index 0000000..82042ae --- /dev/null +++ b/pandoc-pretty/tests/integration.rs @@ -0,0 +1,287 @@ +use assert_cmd::Command; +use predicates::str::contains; +use std::fs; +use std::io::Write; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::process; +use tempfile::tempdir; + +fn katex_fixture_url() -> String { + format!( + "file://{}", + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("katex") + .display() + ) +} + +fn make_fake_pandoc(dir: &PathBuf) -> PathBuf { + let fake = dir.join("pandoc"); + let mut file = fs::File::create(&fake).expect("create fake pandoc"); + writeln!( + file, + r#"#!/bin/sh +if [ "$1" = "--version" ]; then + echo "fake pandoc 0.0.0" + exit 0 +fi +all="$*" +out= +while [ $# -gt 0 ]; do + if [ "$1" = "--output" ]; then shift; out="$1"; fi + shift +done +[ -z "$out" ] && {{ echo "no --output given" >&2; exit 1; }} +printf "\nfake\n" "$all" > "$out" +exit 0 +"# + ) + .expect("write fake pandoc"); + let mut perms = fs::metadata(&fake).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&fake, perms).unwrap(); + fake +} + +#[test] +fn writes_default_output_when_not_provided() { + let tmp = tempdir().unwrap(); + let dir = tmp.path().to_path_buf(); + let fake = make_fake_pandoc(&dir); + + let input = dir.join("note.md"); + fs::write(&input, "# Title\n\nBody").unwrap(); + + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("pandoc-pretty")); + cmd.arg(&input) + .env("PANDOC_PRETTY_KATEX", katex_fixture_url()) + .env( + "PATH", + format!( + "{}:{}", + dir.display(), + std::env::var("PATH").unwrap_or_default() + ), + ); + + cmd.assert().success(); + + let expected_output = dir.join("note.html"); + let html = fs::read_to_string(&expected_output).expect("output exists"); + assert!(html.contains("fake")); + + // ensure fake was used (no real pandoc needed) + assert!(fs::metadata(&fake).unwrap().is_file()); +} + +fn ensure_real_pandoc_available() { + let status = process::Command::new("pandoc") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .expect("failed to spawn pandoc check"); + assert!( + status.success(), + "pandoc not found on PATH; install pandoc to run integration embedding test" + ); +} + +#[test] +fn real_pandoc_embeds_assets_and_template() { + ensure_real_pandoc_available(); + + let tmp = tempdir().unwrap(); + let dir = tmp.path(); + let input = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("full.md"); + let output = dir.join("full.html"); + + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("pandoc-pretty")); + cmd.arg(&input) + .arg(&output) + .env("PANDOC_PRETTY_KATEX", katex_fixture_url()); + cmd.assert().success(); + + let html = fs::read_to_string(&output).unwrap(); + + assert!( + html.contains("pandoc-markdown-css-theme"), + "template marker missing" + ); + assert!( + html.contains("--color-sidenote"), + "embedded CSS not found (color variable missing)" + ); + assert!( + html.contains("class=\"sidenote\"") || html.contains("class=\"marginnote\""), + "sidenote markup missing; lua filter likely not applied" + ); + assert!( + !html.contains("docs/css/theme.css") && !html.contains("skylighting-solarized-theme.css"), + "output still references external CSS files" + ); + assert!( + !html.contains("href=\"/tmp"), + "output still references temp file paths" + ); +} + +#[test] +fn errors_when_pandoc_missing() { + let tmp = tempdir().unwrap(); + let dir = tmp.path(); + let input = dir.join("foo.md"); + fs::write(&input, "text").unwrap(); + let output = dir.join("bar.html"); + + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("pandoc-pretty")); + cmd.arg(&input).arg(&output).env("PATH", ""); + + cmd.assert() + .failure() + .code(127) + .stderr(contains("pandoc not found")); +} + +#[test] +fn adds_fallback_title_and_embeds_resources() { + let tmp = tempdir().unwrap(); + let dir = tmp.path().to_path_buf(); + let fake = make_fake_pandoc(&dir); + + let input = dir.join("readme.md"); + fs::write(&input, "No title here").unwrap(); + + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("pandoc-pretty")); + cmd.arg(&input) + .env("PANDOC_PRETTY_KATEX", katex_fixture_url()) + .env( + "PATH", + format!( + "{}:{}", + dir.display(), + std::env::var("PATH").unwrap_or_default() + ), + ); + + cmd.assert().success(); + + let output = dir.join("readme.html"); + let html = fs::read_to_string(&output).unwrap(); + assert!(html.contains("--embed-resources")); + assert!(html.contains("--standalone")); + assert!(html.contains("--metadata title=readme")); + // Should still include fake marker + assert!(html.contains("fake")); + + assert!(fs::metadata(&fake).unwrap().is_file()); +} + +#[test] +fn aborts_when_user_declines_overwrite() { + let tmp = tempdir().unwrap(); + let dir = tmp.path().to_path_buf(); + let fake = make_fake_pandoc(&dir); + + let input = dir.join("note.md"); + fs::write(&input, "# Title\n\nBody").unwrap(); + + let output = dir.join("note.html"); + fs::write(&output, "keep".as_bytes()).unwrap(); + + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("pandoc-pretty")); + cmd.arg(&input) + .arg(&output) + .env("PANDOC_PRETTY_KATEX", katex_fixture_url()) + .env( + "PATH", + format!( + "{}:{}", + dir.display(), + std::env::var("PATH").unwrap_or_default() + ), + ) + .write_stdin("n\n"); + + cmd.assert() + .failure() + .code(1) + .stderr(contains("aborting; not overwriting")); + + let html = fs::read_to_string(&output).unwrap(); + assert_eq!(html, "keep"); + assert!(fs::metadata(&fake).unwrap().is_file()); +} + +#[test] +fn overwrites_when_user_confirms() { + let tmp = tempdir().unwrap(); + let dir = tmp.path().to_path_buf(); + let fake = make_fake_pandoc(&dir); + + let input = dir.join("note.md"); + fs::write(&input, "# Title\n\nBody").unwrap(); + + let output = dir.join("note.html"); + fs::write(&output, "keep".as_bytes()).unwrap(); + + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("pandoc-pretty")); + cmd.arg(&input) + .arg(&output) + .env("PANDOC_PRETTY_KATEX", katex_fixture_url()) + .env( + "PATH", + format!( + "{}:{}", + dir.display(), + std::env::var("PATH").unwrap_or_default() + ), + ) + .write_stdin("yes\n"); + + cmd.assert().success(); + + let html = fs::read_to_string(&output).unwrap(); + assert!(html.contains("fake")); + assert!(fs::metadata(&fake).unwrap().is_file()); +} + +#[test] +fn preserves_existing_title_metadata() { + let tmp = tempdir().unwrap(); + let dir = tmp.path().to_path_buf(); + let _fake = make_fake_pandoc(&dir); + + let input = dir.join("paper.md"); + fs::write(&input, "---\ntitle: Custom Paper\n---\n\nBody text.\n").unwrap(); + + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("pandoc-pretty")); + cmd.arg(&input) + .env("PANDOC_PRETTY_KATEX", katex_fixture_url()) + .env( + "PATH", + format!( + "{}:{}", + dir.display(), + std::env::var("PATH").unwrap_or_default() + ), + ); + + cmd.assert().success(); + + let output = dir.join("paper.html"); + let html = fs::read_to_string(&output).unwrap(); + assert!( + !html.contains("--metadata title="), + "should not add fallback title when metadata exists" + ); + assert!(html.contains("--embed-resources")); + assert!(html.contains("--standalone")); + assert!(html.contains("fake")); +} diff --git a/pandoc-sidenote.lua b/pandoc-sidenote.lua new file mode 100644 index 0000000..12ca8e5 --- /dev/null +++ b/pandoc-sidenote.lua @@ -0,0 +1,74 @@ +-- Minimal Tufte-style sidenotes for pandoc-markdown-css-theme. +-- Inspired by https://github.com/jez/pandoc-sidenote + +local counter = 0 + +local function drop_margin_marker(blocks) + if #blocks == 0 then + return false + end + + local first = blocks[1] + if first.t ~= "Para" then + return false + end + + local inlines = first.content + if #inlines == 0 then + return false + end + + if inlines[1].t == "Str" and inlines[1].text == "{-}" then + table.remove(inlines, 1) + if #inlines > 0 and inlines[1].t == "Space" then + table.remove(inlines, 1) + end + first.content = inlines + blocks[1] = first + return true + end + + return false +end + +local function blocks_to_inlines(blocks) + if pandoc.utils and pandoc.utils.blocks_to_inlines then + return pandoc.utils.blocks_to_inlines(blocks) + end + -- Fallback: stringify the blocks. + return { pandoc.Str(pandoc.utils.stringify(pandoc.Pandoc(blocks))) } +end + +local function render_note(blocks, margin) + counter = counter + 1 + local id = string.format("%s-%d", margin and "mn" or "sn", counter) + local inlines = blocks_to_inlines(blocks) + + local open = pandoc.RawInline("html", '') + local label = margin + and pandoc.RawInline( + "html", + string.format('', id) + ) + or pandoc.RawInline( + "html", + string.format( + '', + id + ) + ) + local input = pandoc.RawInline( + "html", + string.format('', id) + ) + local span = pandoc.Span(inlines, { class = margin and "marginnote" or "sidenote" }) + local close = pandoc.RawInline("html", "") + + return { open, label, input, span, close } +end + +function Note(note) + local blocks = note.content + local is_margin = drop_margin_marker(blocks) + return render_note(blocks, is_margin) +end