Compare commits

...

13 commits

Author SHA1 Message Date
7bc66067e6 add: bibliography prepend 2026-05-07 15:29:53 -04:00
4e61e4acb3 add: link prepend 2026-05-07 10:59:35 -04:00
agryphus
f13433fc4b add: card prepend 2026-04-29 00:48:06 -04:00
agryphus
e96b613f43 add: comment to figure.typ prepend 2026-04-29 00:39:19 -04:00
agryphus
03d3a31d71 fix: outlines without titles no longer include the first list level 2026-04-29 00:38:40 -04:00
4fc4922256 add: figure plugin 2026-04-28 11:07:22 -04:00
d1a96e1362 chore: cleanup nix dev stuff 2026-04-28 11:07:11 -04:00
agryphus
a6e30123a5 add: format_typst_compile_error 2026-04-27 17:07:40 -04:00
agryphus
4b2918853b add: figure plugin 2026-04-27 13:46:43 -04:00
agryphus
68ed807e95 add: plugin system 2026-04-27 12:22:19 -04:00
agryphus
aa7b1d0a63 add: flag to toggle including top level header in outline 2026-04-25 18:59:22 -04:00
04ebd38691 add: logging 2026-04-23 16:22:36 -04:00
c798e387bd add: recursive flag 2026-04-23 14:43:52 -04:00
14 changed files with 472 additions and 59 deletions

90
Cargo.lock generated
View file

@ -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"
@ -1150,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"
@ -1180,6 +1222,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 +1407,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 +1656,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 +1848,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 +1860,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 +2413,9 @@ name = "typssg"
version = "0.1.0"
dependencies = [
"clap",
"env_logger",
"include_dir",
"log",
"typst",
"typst-as-lib",
"typst-html",

View file

@ -6,7 +6,10 @@ 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"
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"

3
build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
println!("cargo:rerun-if-changed=prepends/");
}

View file

@ -1,8 +0,0 @@
#let image(source, width: "400px") = {
html.elem("img", attrs: (
src: "/static/articles/" + source,
alt: source,
width: str(width),
))
}

17
prepends/bibliography.typ Normal file
View file

@ -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")

22
prepends/card.typ Normal file
View file

@ -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
]
}

19
prepends/figure.typ Normal file
View file

@ -0,0 +1,19 @@
// 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
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
}
}

15
prepends/link.typ Normal file
View file

@ -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
}
}

15
prepends/quote.typ Normal file
View file

@ -0,0 +1,15 @@
// Typst version 14.0.2 outputs a block quote as a <blockquote> followed by
// a <p> for the attribution. This makes it difficult to target the
// attribution for styling. This snippet instead uses a <blockquote> and
// a <figcaption> wrapped in a <figure> 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
}
}

3
rust-toolchain.toml Normal file
View file

@ -0,0 +1,3 @@
[toolchain]
channel = "stable"

View file

@ -1,10 +0,0 @@
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
buildInputs = with pkgs; [
cargo
rustc
rust-analyzer
];
}

View file

@ -1,21 +1,87 @@
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,
prepend: &Option<PathBuf>,
plugins: &[impl AsRef<str>],
include_title: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
info!("compiling {} ...", article_dir.display());
let template_file = article_dir.join("index.typ");
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}",
@ -26,19 +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(&prepend_content);
template.push_str(
&fs::read_to_string(&template_file).map_err(|e| {
format!(
"could not read template {}: {e}",
template_file.display()
)
})?,
);
template.push_str(&plugin_block);
if !plugin_block.is_empty() && !user_prepend.is_empty() {
template.push('\n');
}
template.push_str(&user_prepend);
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)
@ -50,13 +123,27 @@ 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;
parse_outline(&mut doc.root, &mut outline, &mut curr_level);
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 `<ul>` 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("</ul>\n");
}
fs::write(&outline_file, outline.as_bytes()).map_err(|e| {
@ -106,11 +193,51 @@ pub fn compile_article(
Ok(())
}
fn parse_outline(elem: &mut HtmlElement, outline: &mut EcoString, curr_level: &mut u32) {
if matches!(
elem.tag.to_string().as_str(),
"<h2>" | "<h3>" | "<h4>" | "<h5>" | "<h6>"
) {
fn heading_level_from_tag(tag: &str) -> Option<u32> {
match tag {
"<h2>" => Some(2),
"<h3>" => Some(3),
"<h4>" => Some(4),
"<h5>" => Some(5),
"<h6>" => 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,
) {
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 == "<h2>" {
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();
for child in &elem.children {
@ -136,21 +263,26 @@ fn parse_outline(elem: &mut HtmlElement, outline: &mut EcoString, curr_level: &m
.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("<ul>\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("</ul>\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("</ul>\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!(
"<li><a href=\"#{}\">{}</a></li>\n",
@ -165,8 +297,39 @@ 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,
ul_depth,
include_title,
title_h2_pending,
first_outline_heading,
);
}
_ => {}
}
}
}
pub fn compile_all(
root_dir: &PathBuf,
prepend: &Option<PathBuf>,
plugins: &[impl AsRef<str>],
include_title_in_outline: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
for entry in fs::read_dir(root_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
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, plugins, include_title_in_outline)?;
}
}
Ok(())
}

View file

@ -2,7 +2,8 @@ use std::env;
use std::path::PathBuf;
use clap::Parser;
use typssg::compile_article;
use typssg::{compile_all, compile_article};
use log::{info, error};
#[derive(Parser)]
struct Args {
@ -11,18 +12,45 @@ struct Args {
#[arg(long)]
prepend: Option<PathBuf>,
#[arg(long, value_delimiter = ',')]
plugin: Vec<String>,
#[arg(short)]
recursive: bool,
#[arg(long)]
include_title_in_outline: bool,
}
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();
if let Err(e) = compile_article(&args.dir, &args.prepend) {
eprintln!("{e}");
let result = if args.recursive {
compile_all(
&args.dir,
&args.prepend,
&args.plugin,
args.include_title_in_outline,
)
} else {
compile_article(
&args.dir,
&args.prepend,
&args.plugin,
args.include_title_in_outline,
)
};
if let Err(e) = result {
error!("{e}");
std::process::exit(1);
}
}

65
src/plugin.rs Normal file
View file

@ -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<String, String> {
static TABLE: OnceLock<HashMap<String, String>> = 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<String> {
let mut v: Vec<String> = prepends_table().keys().cloned().collect();
v.sort();
v
}
pub fn embedded_prepend_source(id: &str) -> Result<String, String> {
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<str>]) -> Result<String, String> {
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)
}