From c798e387bdbf0f8eb83b804aa609cd83e72bb703 Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 23 Apr 2026 14:43:52 -0400 Subject: [PATCH 01/13] add: recursive flag --- src/lib.rs | 20 ++++++++++++++++++++ src/main.rs | 13 +++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index a03e23d..efb7942 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -170,3 +170,23 @@ fn parse_outline(elem: &mut HtmlElement, outline: &mut EcoString, curr_level: &m } } } + +pub fn compile_all( + root_dir: &PathBuf, + prepend: &Option, +) -> Result<(), Box> { + for entry in fs::read_dir(root_dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + compile_all(&path, prepend)?; + } else if path.file_name().is_some_and(|n| n == "index.typ") { + let dir = path.parent().unwrap().to_path_buf(); + println!("compiling {}", dir.display()); + compile_article(&dir, prepend)?; + } + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index b836b3f..4e5ce4f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ use std::env; use std::path::PathBuf; use clap::Parser; -use typssg::compile_article; +use typssg::{compile_article, compile_all}; #[derive(Parser)] struct Args { @@ -11,6 +11,9 @@ struct Args { #[arg(long)] prepend: Option, + + #[arg(short)] + recursive: bool, } fn main() { @@ -21,7 +24,13 @@ fn main() { let args = Args::parse(); - if let Err(e) = compile_article(&args.dir, &args.prepend) { + let result = if args.recursive { + compile_all(&args.dir, &args.prepend) + } else { + compile_article(&args.dir, &args.prepend) + }; + + if let Err(e) = result { eprintln!("{e}"); std::process::exit(1); } From 04ebd38691e148cf9c54df99b96df7fcf5d7ed9c Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 23 Apr 2026 16:22:36 -0400 Subject: [PATCH 02/13] add: logging --- Cargo.lock | 70 ++++++++++++++++++++++++++++++++++++++++++++++++----- Cargo.toml | 2 ++ src/lib.rs | 3 ++- src/main.rs | 9 ++++--- 4 files changed, 74 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1e3034e..b0b1af1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -554,6 +554,29 @@ dependencies = [ "syn", ] +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1180,6 +1203,30 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jiff" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "js-sys" version = "0.3.77" @@ -1341,9 +1388,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "memchr" @@ -1590,6 +1637,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "postcard" version = "1.1.1" @@ -1773,9 +1829,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -1785,9 +1841,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -2338,6 +2394,8 @@ name = "typssg" version = "0.1.0" dependencies = [ "clap", + "env_logger", + "log", "typst", "typst-as-lib", "typst-html", diff --git a/Cargo.toml b/Cargo.toml index fd7f150..cabf63d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,8 @@ edition = "2021" [dependencies] clap = { version = "4.6.1", features = ["derive"] } +env_logger = "0.11.10" +log = "0.4.29" typst = "0.14.2" typst-as-lib = { version = "0.15.4", features = ["typst-html", "typst-kit-fonts", "typst-kit-embed-fonts"] } typst-html = "0.14.2" diff --git a/src/lib.rs b/src/lib.rs index efb7942..258b55c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,12 +4,14 @@ use std::path::PathBuf; use typst::ecow::EcoString; use typst_as_lib::{typst_kit_options::TypstKitFontOptions, TypstEngine}; use typst_html::{HtmlAttr, HtmlDocument, HtmlElement, HtmlNode}; +use log::info; pub fn compile_article( article_dir: &PathBuf, prepend: &Option, ) -> Result<(), Box> { + info!("compiling {} ...", article_dir.display()); let template_file = article_dir.join("index.typ"); let output = article_dir.join("index.html"); @@ -183,7 +185,6 @@ pub fn compile_all( compile_all(&path, prepend)?; } else if path.file_name().is_some_and(|n| n == "index.typ") { let dir = path.parent().unwrap().to_path_buf(); - println!("compiling {}", dir.display()); compile_article(&dir, prepend)?; } } diff --git a/src/main.rs b/src/main.rs index 4e5ce4f..6529a6c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use clap::Parser; use typssg::{compile_article, compile_all}; +use log::{info, error}; #[derive(Parser)] struct Args { @@ -17,9 +18,11 @@ struct Args { } fn main() { + env_logger::init(); + match env::current_dir() { - Ok(path) => println!("Current working directory: {}", path.display()), - Err(e) => eprintln!("Error getting current directory: {}", e), + Ok(path) => info!("Starting in working directory: {}", path.display()), + Err(e) => error!("Error getting current directory: {}", e), } let args = Args::parse(); @@ -31,7 +34,7 @@ fn main() { }; if let Err(e) = result { - eprintln!("{e}"); + error!("{e}"); std::process::exit(1); } } From aa7b1d0a63181f7ebaca95d25bab7c8cc96a5ea3 Mon Sep 17 00:00:00 2001 From: agryphus Date: Sat, 25 Apr 2026 18:59:22 -0400 Subject: [PATCH 03/13] add: flag to toggle including top level header in outline --- src/lib.rs | 21 ++++++++++++++++----- src/main.rs | 7 +++++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 258b55c..7c5d9dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ use log::info; pub fn compile_article( article_dir: &PathBuf, prepend: &Option, + include_title: bool, ) -> Result<(), Box> { info!("compiling {} ...", article_dir.display()); @@ -56,7 +57,7 @@ pub fn compile_article( let mut outline = EcoString::new(); let mut curr_level = 1; - parse_outline(&mut doc.root, &mut outline, &mut curr_level); + parse_outline(&mut doc.root, &mut outline, &mut curr_level, include_title); for i in (1..curr_level).rev() { outline.push_str(" ".repeat(i as usize - 1).as_str()); outline.push_str("\n"); @@ -108,11 +109,20 @@ pub fn compile_article( Ok(()) } -fn parse_outline(elem: &mut HtmlElement, outline: &mut EcoString, curr_level: &mut u32) { +fn parse_outline( + elem: &mut HtmlElement, + outline: &mut EcoString, + curr_level: &mut u32, + include_title: bool, +) { if matches!( elem.tag.to_string().as_str(), "

" | "

" | "

" | "

" | "
" ) { + if !include_title && elem.tag.to_string().as_str() == "

" { + return; + } + let mut header_text = EcoString::new(); for child in &elem.children { @@ -167,7 +177,7 @@ fn parse_outline(elem: &mut HtmlElement, outline: &mut EcoString, curr_level: &m for child in elem.children.make_mut().iter_mut() { match child { - HtmlNode::Element(e) => parse_outline(e, outline, curr_level), + HtmlNode::Element(e) => parse_outline(e, outline, curr_level, include_title), _ => {} } } @@ -176,16 +186,17 @@ fn parse_outline(elem: &mut HtmlElement, outline: &mut EcoString, curr_level: &m pub fn compile_all( root_dir: &PathBuf, prepend: &Option, + include_title_in_outline: bool, ) -> Result<(), Box> { for entry in fs::read_dir(root_dir)? { let entry = entry?; let path = entry.path(); if path.is_dir() { - compile_all(&path, prepend)?; + compile_all(&path, prepend, include_title_in_outline)?; } else if path.file_name().is_some_and(|n| n == "index.typ") { let dir = path.parent().unwrap().to_path_buf(); - compile_article(&dir, prepend)?; + compile_article(&dir, prepend, include_title_in_outline)?; } } diff --git a/src/main.rs b/src/main.rs index 6529a6c..787f433 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,9 @@ struct Args { #[arg(short)] recursive: bool, + + #[arg(long)] + include_title_in_outline: bool, } fn main() { @@ -28,9 +31,9 @@ fn main() { let args = Args::parse(); let result = if args.recursive { - compile_all(&args.dir, &args.prepend) + compile_all(&args.dir, &args.prepend, args.include_title_in_outline) } else { - compile_article(&args.dir, &args.prepend) + compile_article(&args.dir, &args.prepend, args.include_title_in_outline) }; if let Err(e) = result { From 68ed807e951804e5b711b362d51552e55a904e1b Mon Sep 17 00:00:00 2001 From: agryphus Date: Mon, 27 Apr 2026 12:22:19 -0400 Subject: [PATCH 04/13] add: plugin system --- Cargo.lock | 20 ++++++++++++++ Cargo.toml | 1 + common.typ | 8 ------ prepends/image.typ | 1 + src/lib.rs | 20 +++++++++++--- src/main.rs | 19 +++++++++++--- src/plugin.rs | 65 ++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 119 insertions(+), 15 deletions(-) delete mode 100644 common.typ create mode 100644 prepends/image.typ create mode 100644 src/plugin.rs diff --git a/Cargo.lock b/Cargo.lock index b0b1af1..c6be5de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1173,6 +1173,25 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -2395,6 +2414,7 @@ version = "0.1.0" dependencies = [ "clap", "env_logger", + "include_dir", "log", "typst", "typst-as-lib", diff --git a/Cargo.toml b/Cargo.toml index cabf63d..28eeeb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +include_dir = "0.7.4" clap = { version = "4.6.1", features = ["derive"] } env_logger = "0.11.10" log = "0.4.29" diff --git a/common.typ b/common.typ deleted file mode 100644 index eabfd0a..0000000 --- a/common.typ +++ /dev/null @@ -1,8 +0,0 @@ -#let image(source, width: "400px") = { - html.elem("img", attrs: ( - src: "/static/articles/" + source, - alt: source, - width: str(width), - )) -} - diff --git a/prepends/image.typ b/prepends/image.typ new file mode 100644 index 0000000..10456fb --- /dev/null +++ b/prepends/image.typ @@ -0,0 +1 @@ +#let image = (..) => [IMAGE] diff --git a/src/lib.rs b/src/lib.rs index 7c5d9dd..dbf877e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,10 @@ +mod plugin; + use std::fs; use std::path::PathBuf; +pub use plugin::{concat_plugin_sources, embedded_prepend_source, list_embedded_plugin_ids}; + use typst::ecow::EcoString; use typst_as_lib::{typst_kit_options::TypstKitFontOptions, TypstEngine}; use typst_html::{HtmlAttr, HtmlDocument, HtmlElement, HtmlNode}; @@ -10,6 +14,7 @@ use log::info; pub fn compile_article( article_dir: &PathBuf, prepend: &Option, + plugins: &[impl AsRef], include_title: bool, ) -> Result<(), Box> { info!("compiling {} ...", article_dir.display()); @@ -18,7 +23,9 @@ pub fn compile_article( let output = article_dir.join("index.html"); let outline_file = article_dir.join("outline.html"); - let prepend_content = if let Some(prepend_file) = prepend { + let plugin_block = concat_plugin_sources(plugins)?; + + let user_prepend = if let Some(prepend_file) = prepend { fs::read_to_string(&prepend_file).map_err(|e| { format!( "could not read prepend file {}: {e}", @@ -30,7 +37,11 @@ pub fn compile_article( }; let mut template: EcoString = EcoString::new(); - template.push_str(&prepend_content); + template.push_str(&plugin_block); + if !plugin_block.is_empty() && !user_prepend.is_empty() { + template.push('\n'); + } + template.push_str(&user_prepend); template.push_str( &fs::read_to_string(&template_file).map_err(|e| { format!( @@ -186,6 +197,7 @@ fn parse_outline( pub fn compile_all( root_dir: &PathBuf, prepend: &Option, + plugins: &[impl AsRef], include_title_in_outline: bool, ) -> Result<(), Box> { for entry in fs::read_dir(root_dir)? { @@ -193,10 +205,10 @@ pub fn compile_all( let path = entry.path(); if path.is_dir() { - compile_all(&path, prepend, include_title_in_outline)?; + compile_all(&path, prepend, plugins, include_title_in_outline)?; } else if path.file_name().is_some_and(|n| n == "index.typ") { let dir = path.parent().unwrap().to_path_buf(); - compile_article(&dir, prepend, include_title_in_outline)?; + compile_article(&dir, prepend, plugins, include_title_in_outline)?; } } diff --git a/src/main.rs b/src/main.rs index 787f433..ba7c1a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ use std::env; use std::path::PathBuf; use clap::Parser; -use typssg::{compile_article, compile_all}; +use typssg::{compile_all, compile_article}; use log::{info, error}; #[derive(Parser)] @@ -13,6 +13,9 @@ struct Args { #[arg(long)] prepend: Option, + #[arg(long, value_delimiter = ',')] + plugin: Vec, + #[arg(short)] recursive: bool, @@ -31,9 +34,19 @@ fn main() { let args = Args::parse(); let result = if args.recursive { - compile_all(&args.dir, &args.prepend, args.include_title_in_outline) + compile_all( + &args.dir, + &args.prepend, + &args.plugin, + args.include_title_in_outline, + ) } else { - compile_article(&args.dir, &args.prepend, args.include_title_in_outline) + compile_article( + &args.dir, + &args.prepend, + &args.plugin, + args.include_title_in_outline, + ) }; if let Err(e) = result { diff --git a/src/plugin.rs b/src/plugin.rs new file mode 100644 index 0000000..3f17a7d --- /dev/null +++ b/src/plugin.rs @@ -0,0 +1,65 @@ +use std::collections::HashMap; +use std::sync::OnceLock; + +use include_dir::{include_dir, Dir}; + +static PREPENDS_DIR: Dir<'static> = include_dir!("prepends"); + +fn prepends_table() -> &'static HashMap { + static TABLE: OnceLock> = OnceLock::new(); + TABLE.get_or_init(|| { + let mut m = HashMap::new(); + for file in PREPENDS_DIR.files() { + let Some(path_str) = file.path().to_str() else { + continue; + }; + if !path_str.ends_with(".typ") { + continue; + } + let id = path_str[..path_str.len() - 4].replace('\\', "/"); + let text = file + .contents_utf8() + .unwrap_or_else(|| panic!("prepends/{path_str} is not valid UTF-8")) + .to_string(); + if m.insert(id.clone(), text).is_some() { + panic!("duplicate prepend plugin id after normalize: {id}"); + } + } + m + }) +} + +pub fn list_embedded_plugin_ids() -> Vec { + let mut v: Vec = prepends_table().keys().cloned().collect(); + v.sort(); + v +} + +pub fn embedded_prepend_source(id: &str) -> Result { + let id = id.trim().replace('\\', "/"); + if id.is_empty() { + return Err("empty plugin id".into()); + } + prepends_table() + .get(&id) + .cloned() + .ok_or_else(|| { + let known = list_embedded_plugin_ids().join(", "); + if known.is_empty() { + format!("unknown plugin '{id}' (no embedded prepends in this build)") + } else { + format!("unknown plugin '{id}' (embedded: {known})") + } + }) +} + +pub fn concat_plugin_sources(plugin_ids: &[impl AsRef]) -> Result { + let mut out = String::new(); + for (i, id) in plugin_ids.iter().enumerate() { + if i > 0 { + out.push('\n'); + } + out.push_str(&embedded_prepend_source(id.as_ref())?); + } + Ok(out) +} From 4b2918853b0c983d93cd786bf52d8a46d9847b5c Mon Sep 17 00:00:00 2001 From: agryphus Date: Mon, 27 Apr 2026 13:46:43 -0400 Subject: [PATCH 05/13] add: figure plugin --- build.rs | 3 +++ prepends/figure.typ | 15 +++++++++++++++ prepends/image.typ | 1 - 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 build.rs create mode 100644 prepends/figure.typ delete mode 100644 prepends/image.typ diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..e5458ff --- /dev/null +++ b/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rerun-if-changed=prepends/"); +} diff --git a/prepends/figure.typ b/prepends/figure.typ new file mode 100644 index 0000000..c053398 --- /dev/null +++ b/prepends/figure.typ @@ -0,0 +1,15 @@ +#show figure: it => { + if it.body.func() == image { + let src = it.body.source + let dot-pos = src.rev().position(".") + let full-src = if dot-pos != none { + src.slice(0, src.len() - dot-pos - 1) + "_full" + src.slice(src.len() - dot-pos - 1) + } else { + src + "_full" + } + show image: img => link(full-src, img) + it + } else { + it + } +} diff --git a/prepends/image.typ b/prepends/image.typ deleted file mode 100644 index 10456fb..0000000 --- a/prepends/image.typ +++ /dev/null @@ -1 +0,0 @@ -#let image = (..) => [IMAGE] From a6e30123a58960a72db44ac2f3e657aa6d56fbf6 Mon Sep 17 00:00:00 2001 From: agryphus Date: Mon, 27 Apr 2026 17:07:40 -0400 Subject: [PATCH 06/13] add: format_typst_compile_error --- src/lib.rs | 81 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 11 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index dbf877e..59c4a0f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,15 +1,71 @@ mod plugin; +use std::fmt::Write; use std::fs; use std::path::PathBuf; pub use plugin::{concat_plugin_sources, embedded_prepend_source, list_embedded_plugin_ids}; use typst::ecow::EcoString; -use typst_as_lib::{typst_kit_options::TypstKitFontOptions, TypstEngine}; +use typst::syntax::Source; +use typst_as_lib::{typst_kit_options::TypstKitFontOptions, TypstAsLibError, TypstEngine}; use typst_html::{HtmlAttr, HtmlDocument, HtmlElement, HtmlNode}; use log::info; +fn format_typst_compile_error( + err: TypstAsLibError, + full_source: &str, + index_byte_start: usize, + index_source: &str, +) -> std::io::Error { + let report = match err { + TypstAsLibError::TypstSource(diagnostics) if !diagnostics.is_empty() => { + let combined = Source::detached(full_source); + let index_only = Source::detached(index_source); + let index_end = index_byte_start.saturating_add(index_source.len()); + let mut out = String::from("Typst compile failed:\n"); + for d in diagnostics.iter() { + let msg = d.message.as_str(); + if let Some(range) = combined.range(d.span) { + let byte = range.start; + if byte >= index_byte_start && byte < index_end { + let rel = byte - index_byte_start; + if let Some((line, col)) = index_only.lines().byte_to_line_column(rel) { + let _ = writeln!( + &mut out, + " index.typ:{}:{}: {}", + line + 1, + col + 1, + msg + ); + } else { + let _ = writeln!(&mut out, " {msg}"); + } + } else if let Some((line, col)) = combined.lines().byte_to_line_column(byte) { + let _ = writeln!( + &mut out, + " (preamble) line {}:{}: {}", + line + 1, + col + 1, + msg + ); + } else { + let _ = writeln!(&mut out, " {msg}"); + } + } else { + let _ = writeln!(&mut out, " {msg}"); + } + for hint in d.hints.iter() { + let _ = writeln!(&mut out, " hint: {}", hint.as_str()); + } + } + out + } + other => format!("typst compile failed: {other}"), + }; + std::io::Error::new(std::io::ErrorKind::Other, report) +} + pub fn compile_article( article_dir: &PathBuf, @@ -36,23 +92,26 @@ pub fn compile_article( fs::read_to_string(article_dir.join("prepend.typ")).unwrap_or_default() }; + let index_source = fs::read_to_string(&template_file).map_err(|e| { + format!( + "could not read template {}: {e}", + template_file.display() + ) + })?; + let mut template: EcoString = EcoString::new(); template.push_str(&plugin_block); if !plugin_block.is_empty() && !user_prepend.is_empty() { template.push('\n'); } template.push_str(&user_prepend); - template.push_str( - &fs::read_to_string(&template_file).map_err(|e| { - format!( - "could not read template {}: {e}", - template_file.display() - ) - })?, - ); + let index_byte_start = template.len(); + template.push_str(&index_source); + + let full_source_str = template.to_string(); let engine = TypstEngine::builder() - .main_file(template.to_string()) + .main_file(full_source_str.clone()) .search_fonts_with( TypstKitFontOptions::default() .include_system_fonts(false) @@ -64,7 +123,7 @@ pub fn compile_article( let mut doc: HtmlDocument = engine .compile() .output - .map_err(|e| format!("typst compile failed: {e}"))?; + .map_err(|e| format_typst_compile_error(e, &full_source_str, index_byte_start, &index_source))?; let mut outline = EcoString::new(); let mut curr_level = 1; From d1a96e136261437640db078bb1db42bb1aa84974 Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 28 Apr 2026 11:07:11 -0400 Subject: [PATCH 07/13] chore: cleanup nix dev stuff --- rust-toolchain.toml | 3 +++ shell.nix | 10 ---------- 2 files changed, 3 insertions(+), 10 deletions(-) create mode 100644 rust-toolchain.toml delete mode 100644 shell.nix diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..9cab269 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" + diff --git a/shell.nix b/shell.nix deleted file mode 100644 index afd09a7..0000000 --- a/shell.nix +++ /dev/null @@ -1,10 +0,0 @@ -{ pkgs ? import {} }: - -pkgs.mkShell { - buildInputs = with pkgs; [ - cargo - rustc - rust-analyzer - ]; -} - From 4fc4922256843875b2c832ebcd4bf51cbd87afd6 Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 28 Apr 2026 11:07:22 -0400 Subject: [PATCH 08/13] add: figure plugin --- prepends/quote.typ | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 prepends/quote.typ diff --git a/prepends/quote.typ b/prepends/quote.typ new file mode 100644 index 0000000..bba122d --- /dev/null +++ b/prepends/quote.typ @@ -0,0 +1,15 @@ +// Typst version 14.0.2 outputs a block quote as a
followed by +// a

for the attribution. This makes it difficult to target the +// attribution for styling. This snippet instead uses a

and +// a
wrapped in a
block. +#show quote.where(block: true): it => { + let inner = html.elem("blockquote", it.body) + if it.attribution != none { + html.elem("figure", { + inner + html.elem("figcaption", it.attribution) + }) + } else { + inner + } +} From 03d3a31d710d200bb5f75e41eb13f726edbd1f4a Mon Sep 17 00:00:00 2001 From: agryphus Date: Wed, 29 Apr 2026 00:38:40 -0400 Subject: [PATCH 09/13] fix: outlines without titles no longer include the first list level --- src/lib.rs | 96 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 78 insertions(+), 18 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 59c4a0f..34bc866 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -126,10 +126,24 @@ pub fn compile_article( .map_err(|e| format_typst_compile_error(e, &full_source_str, index_byte_start, &index_source))?; let mut outline = EcoString::new(); - let mut curr_level = 1; - parse_outline(&mut doc.root, &mut outline, &mut curr_level, include_title); - for i in (1..curr_level).rev() { - outline.push_str(" ".repeat(i as usize - 1).as_str()); + let mut curr_level = 1u32; + let mut ul_depth = 0u32; + let mut title_h2_pending = !include_title; + let mut first_outline_heading = true; + parse_outline( + &mut doc.root, + &mut outline, + &mut curr_level, + &mut ul_depth, + include_title, + &mut title_h2_pending, + &mut first_outline_heading, + ); + // `ul_depth` counts open `
    ` tags; it can diverge from `curr_level - 1` when the first + // outline heading uses lazy depth (skip title). Always drain by `ul_depth`, not `curr_level`. + while ul_depth > 0 { + ul_depth -= 1; + outline.push_str(" ".repeat(ul_depth as usize).as_str()); outline.push_str("
\n"); } fs::write(&outline_file, outline.as_bytes()).map_err(|e| { @@ -179,19 +193,50 @@ pub fn compile_article( Ok(()) } +fn heading_level_from_tag(tag: &str) -> Option { + match tag { + "

" => Some(2), + "

" => Some(3), + "

" => Some(4), + "

" => Some(5), + "
" => Some(6), + _ => None, + } +} + fn parse_outline( elem: &mut HtmlElement, outline: &mut EcoString, curr_level: &mut u32, + ul_depth: &mut u32, include_title: bool, + title_h2_pending: &mut bool, + first_outline_heading: &mut bool, ) { - if matches!( - elem.tag.to_string().as_str(), - "

" | "

" | "

" | "

" | "
" - ) { - if !include_title && elem.tag.to_string().as_str() == "

" { - return; + let tag = elem.tag.to_string(); + let tag_ref = tag.as_str(); + + if let Some(level) = heading_level_from_tag(tag_ref) { + if !include_title && *title_h2_pending { + *title_h2_pending = false; + if tag_ref == "

" { + for child in elem.children.make_mut().iter_mut() { + if let HtmlNode::Element(e) = child { + parse_outline( + e, + outline, + curr_level, + ul_depth, + include_title, + title_h2_pending, + first_outline_heading, + ); + } + } + return; + } } + *title_h2_pending = false; let mut header_text = EcoString::new(); @@ -218,21 +263,26 @@ fn parse_outline( .trim_matches('-') .replace("--", "-"); - let level: u32 = elem.tag.to_string().chars().nth(2).unwrap() as u32 - '0' as u32; + if *first_outline_heading { + *first_outline_heading = false; + *curr_level = level.saturating_sub(1); + } while level > *curr_level { - *curr_level += 1; - outline.push_str(" ".repeat(*curr_level as usize - 2).as_str()); + outline.push_str(" ".repeat(*ul_depth as usize).as_str()); outline.push_str("
    \n"); + *ul_depth += 1; + *curr_level += 1; } - while level < *curr_level { - outline.push_str(" ".repeat(*curr_level as usize - 2).as_str()); - outline.push_str("
\n"); + while level < *curr_level && *ul_depth > 0 { *curr_level -= 1; + *ul_depth -= 1; + outline.push_str(" ".repeat(*ul_depth as usize).as_str()); + outline.push_str("\n"); } *curr_level = level; - outline.push_str(" ".repeat(*curr_level as usize - 1).as_str()); + outline.push_str(" ".repeat(*ul_depth as usize).as_str()); outline.push_str( format!( "
  • {}
  • \n", @@ -247,7 +297,17 @@ fn parse_outline( for child in elem.children.make_mut().iter_mut() { match child { - HtmlNode::Element(e) => parse_outline(e, outline, curr_level, include_title), + HtmlNode::Element(e) => { + parse_outline( + e, + outline, + curr_level, + ul_depth, + include_title, + title_h2_pending, + first_outline_heading, + ); + } _ => {} } } From e96b613f4350960fc03d80cbbdbad02a0be9b71c Mon Sep 17 00:00:00 2001 From: agryphus Date: Wed, 29 Apr 2026 00:39:19 -0400 Subject: [PATCH 10/13] add: comment to figure.typ prepend --- prepends/figure.typ | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/prepends/figure.typ b/prepends/figure.typ index c053398..73ec4c8 100644 --- a/prepends/figure.typ +++ b/prepends/figure.typ @@ -1,3 +1,7 @@ +// Given a figure with an image, this show rule will wrap the image in a link +// that goes to the full version of the pic. For example, image("bird.jpg") +// will get wrapped in a #link("bird_full.jpg"). This, of course, assumes +// bird_full.jpg also exists in the directory. #show figure: it => { if it.body.func() == image { let src = it.body.source From f13433fc4b07ce3154918ca5a45c3d925229bc03 Mon Sep 17 00:00:00 2001 From: agryphus Date: Wed, 29 Apr 2026 00:48:06 -0400 Subject: [PATCH 11/13] add: card prepend --- prepends/card.typ | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 prepends/card.typ diff --git a/prepends/card.typ b/prepends/card.typ new file mode 100644 index 0000000..25cc3e0 --- /dev/null +++ b/prepends/card.typ @@ -0,0 +1,22 @@ +#let card( + caption: "", + media: (), + score: 0, + defense: "", + offense: "", +) = { + text[= #caption (Card) + + #html.elem("div", attrs: (class: "card"))[ + #media.at(0) + ] + + score: #score + + == Defense + #defense + + == Offense + #offense + ] +} From 4e61e4acb3135c08abc6cf16f1a1e48fd8d3dcc0 Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 7 May 2026 10:59:35 -0400 Subject: [PATCH 12/13] add: link prepend --- prepends/link.typ | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 prepends/link.typ diff --git a/prepends/link.typ b/prepends/link.typ new file mode 100644 index 0000000..ad107f9 --- /dev/null +++ b/prepends/link.typ @@ -0,0 +1,15 @@ +// Adds an "internal" class to links that stay within the application, +// allowing them to be styled differently from external links. +#show link: it => { + let dest = it.dest + if type(dest) == str { + let is-internal = dest.starts-with("/") or dest.starts-with(".") + let attrs = (href: dest) + if is-internal { + attrs = (href: dest, class: "internal") + } + html.elem("a", attrs: attrs, it.body) + } else { + it + } +} From 7bc66067e63a7ad52da33cc0e6c82ffec23f6743 Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 7 May 2026 15:28:28 -0400 Subject: [PATCH 13/13] add: bibliography prepend --- prepends/bibliography.typ | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 prepends/bibliography.typ diff --git a/prepends/bibliography.typ b/prepends/bibliography.typ new file mode 100644 index 0000000..f72b036 --- /dev/null +++ b/prepends/bibliography.typ @@ -0,0 +1,17 @@ +// By default, bibliography titles render with a level 1 heading. Typssg +// rather assumes that lvl 1 headings are just for page titles, and that +// section titles are denoted with lvl 2 headings. +#let _bibliography = bibliography +#let refs(content) = { + heading(level: 2, "References") + _bibliography(bytes(content.text), style: "ieee", title: none) +} + +// Override default bibliography so that users don't accidentally use it +#let bibliography = (path, ..args) => { + text[This project has enabled the bibliography extension which provides the `#refs()` funciton for convenient in-file bibliographies. If you _did_ intend to use the default bibliography function, call it instead with `#_bibliography().`] +} + +// Have in-text citations appear as just numbers, instead of the IEEE default +// style of [#] with brackets. +#set cite(style: "vancouver-superscript")