Skip to content

feat(markdown): add support for math rendering with typst and katex #2791

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 29 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6f3df32
feat(markdown): math rendering with typst
cestef Feb 7, 2025
f4373cc
fix: remove useless ENABLE_GFM option
cestef Feb 7, 2025
c62de4c
fix: extra closing tags being added in codeblocks
cestef Feb 7, 2025
47c4852
fix: allow inline html for summaries
cestef Feb 7, 2025
e64b7b2
fix: formatting
cestef Feb 7, 2025
a2760b9
fix: math engine `none` panic
cestef Feb 9, 2025
6b6448b
feat: support `cache_dir` to control where the cache is saved for math
cestef Feb 9, 2025
4444203
docs: start writing docs for math rendering
cestef Feb 9, 2025
1b595d2
feat(markdown): add basic tests for typst
cestef Feb 9, 2025
0a33bda
feat(markdown): cache typst packages to the correct directory
cestef Feb 9, 2025
242f974
feat: basic katex rendering
cestef Feb 9, 2025
8f2bc47
feat: complete katex rendering + a bit of refactoring
cestef Feb 9, 2025
052b432
fix: katex test
cestef Feb 9, 2025
733ea26
fix: switch to `duktape` js engine for katex
cestef Feb 10, 2025
a97d342
chore: update Cargo.lock
cestef Feb 10, 2025
df62c23
chore: update Cargo.lock
cestef Feb 18, 2025
3c84dd8
refactor: math configuration
cestef Feb 19, 2025
3cd9bd0
fix: don't use nightly feature flags
cestef Feb 19, 2025
cf3af3a
fix: set the correct cache depending on the engine
cestef Feb 19, 2025
54a444e
refactor: create cache only on write
cestef Feb 19, 2025
51ba6d9
fix: don't build codeblock for rendered codeblocks
cestef Feb 19, 2025
72a3517
fix: deserialize HashMap instead of Option<HashMap>
cestef Feb 19, 2025
fd349a4
feat: support `math = "engine"`
cestef Feb 19, 2025
1ec97e3
fix: infinite recursion when deserializing config
cestef Feb 19, 2025
3ed02a5
refactor: remove `CodeBlockType`
cestef Feb 19, 2025
40c79a2
refactor: remove `math = "engine"` syntax support
cestef Feb 19, 2025
7ba158a
docs: update math documentation
cestef Feb 19, 2025
ec4bc5c
docs: typo
cestef Feb 19, 2025
ee72c04
Merge branch 'next' into feature/math-rendering
cestef Feb 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,185 changes: 1,154 additions & 31 deletions Cargo.lock

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions components/config/src/config/markup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ pub struct ThemeCss {
pub filename: String,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum MathRendering {
#[default]
None,
Typst,
KaTeX,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct Markdown {
Expand Down Expand Up @@ -65,6 +74,19 @@ pub struct Markdown {
/// Whether to insert a link for each header like the ones you can see in this site if you hover one
/// The default template can be overridden by creating a `anchor-link.html` in the `templates` directory
pub insert_anchor_links: InsertAnchor,
/// Whether to enable math rendering in markdown files
pub math: MathRendering,
/// Whether to optimize generated math SVGs with svgo
pub math_svgo: bool,
/// Svgo configuration file path
pub math_svgo_config: Option<String>,
/// Injected CSS path for math rendering
pub math_css: Option<String>,
/// The directory where the cache for the math rendering and other stuff will be stored
pub cache_dir: Option<String>,
/// Whether to cache the rendered math
#[serde(skip)]
pub cache: bool,
}

impl Markdown {
Expand Down Expand Up @@ -240,6 +262,12 @@ impl Default for Markdown {
extra_theme_set: Arc::new(None),
lazy_async_image: false,
insert_anchor_links: InsertAnchor::None,
math: MathRendering::default(),
math_svgo: false,
math_css: None,
math_svgo_config: None,
cache_dir: None,
cache: true,
}
}
}
1 change: 1 addition & 0 deletions components/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub use crate::config::{
languages::LanguageOptions,
link_checker::LinkChecker,
link_checker::LinkCheckerLevel,
markup::MathRendering,
search::{IndexFormat, Search},
slugify::Slugify,
taxonomies::TaxonomyConfig,
Expand Down
13 changes: 13 additions & 0 deletions components/content/src/page.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
/// A page, can be a blog post or a basic page
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use libs::once_cell::sync::Lazy;
use libs::regex::Regex;
use libs::tera::{Context as TeraContext, Tera};

use config::Config;
use errors::{Context, Result};
use markdown::context::Caches;

use markdown::{render_content, RenderContext};
use utils::slugs::slugify_paths;
use utils::table_of_contents::Heading;
Expand Down Expand Up @@ -212,6 +215,7 @@ impl Page {
config: &Config,
anchor_insert: InsertAnchor,
shortcode_definitions: &HashMap<String, ShortcodeDefinition>,
caches: Arc<Caches>,
) -> Result<()> {
let mut context = RenderContext::new(
tera,
Expand All @@ -220,9 +224,12 @@ impl Page {
&self.permalink,
permalinks,
anchor_insert,
caches,
);
context.set_shortcode_definitions(shortcode_definitions);
context.set_current_page_path(&self.file.relative);
context.set_parent_absolute(&self.file.parent);

context.tera_context.insert("page", &SerializingPage::new(self, None, false));

let res = render_content(&self.raw_content, &context)
Expand Down Expand Up @@ -300,8 +307,10 @@ mod tests {
use std::fs::{create_dir, File};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use libs::globset::{Glob, GlobSetBuilder};
use markdown::context::Caches;
use tempfile::tempdir;
use templates::ZOLA_TERA;

Expand Down Expand Up @@ -329,6 +338,7 @@ Hello world"#;
&config,
InsertAnchor::None,
&HashMap::new(),
Arc::new(Caches::default()),
)
.unwrap();

Expand Down Expand Up @@ -357,6 +367,7 @@ Hello world"#;
&config,
InsertAnchor::None,
&HashMap::new(),
Arc::new(Caches::default()),
)
.unwrap();

Expand Down Expand Up @@ -527,6 +538,7 @@ Hello world
&config,
InsertAnchor::None,
&HashMap::new(),
Arc::new(Caches::default()),
)
.unwrap();
assert_eq!(page.summary, Some("<p>Hello world</p>".to_string()));
Expand Down Expand Up @@ -561,6 +573,7 @@ And here's another. [^3]
&config,
InsertAnchor::None,
&HashMap::new(),
Arc::new(Caches::default()),
)
.unwrap();
assert_eq!(
Expand Down
5 changes: 5 additions & 0 deletions components/content/src/section.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use libs::tera::{Context as TeraContext, Tera};

use config::Config;
use errors::{Context, Result};
use markdown::context::Caches;
use markdown::{render_content, RenderContext};
use utils::fs::read_file;
use utils::net::is_external_link;
Expand Down Expand Up @@ -150,6 +152,7 @@ impl Section {
tera: &Tera,
config: &Config,
shortcode_definitions: &HashMap<String, ShortcodeDefinition>,
caches: Arc<Caches>,
) -> Result<()> {
let mut context = RenderContext::new(
tera,
Expand All @@ -158,9 +161,11 @@ impl Section {
&self.permalink,
permalinks,
self.meta.insert_anchor_links.unwrap_or(config.markdown.insert_anchor_links),
caches,
);
context.set_shortcode_definitions(shortcode_definitions);
context.set_current_page_path(&self.file.relative);
context.set_parent_absolute(&self.file.parent);
context
.tera_context
.insert("section", &SerializingSection::new(self, SectionSerMode::ForMarkdown));
Expand Down
23 changes: 20 additions & 3 deletions components/libs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,22 @@ ammonia = "4"
atty = "0.2.11"
base64 = "0.22"
csv = "1"
elasticlunr-rs = { version = "3.0.2", features = ["da", "no", "de", "du", "es", "fi", "fr", "hu", "it", "pt", "ro", "ru", "sv", "tr"] }
elasticlunr-rs = { version = "3.0.2", features = [
"da",
"no",
"de",
"du",
"es",
"fi",
"fr",
"hu",
"it",
"pt",
"ro",
"ru",
"sv",
"tr",
] }
filetime = "0.2"
gh-emoji = "1"
glob = "0.3"
Expand All @@ -27,8 +42,10 @@ quickxml_to_serde = "0.6"
rayon = "1"
regex = "1"
relative-path = "1"
reqwest = { version = "0.12", default-features = false, features = ["blocking"] }
grass = {version = "0.13", default-features = false, features = ["random"]}
reqwest = { version = "0.12", default-features = false, features = [
"blocking",
] }
grass = { version = "0.13", default-features = false, features = ["random"] }
serde_json = "1"
serde_yaml = "0.9"
sha2 = "0.10"
Expand Down
15 changes: 15 additions & 0 deletions components/markdown/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ config = { path = "../config" }
console = { path = "../console" }
libs = { path = "../libs" }

typst = "0.12.0"
typst-assets = { version = "0.12.0", features = ["fonts"] }
typst-svg = "0.12.0"
time = { version = "0.3.37", features = ["local-offset"] }
flate2 = "1.0.35"
tar = "0.4.43"
ttf-parser = "0.25.1"
urlencoding = "2.1.3"
bincode = "1.3.3"
serde = { version = "1.0.130", features = ["derive"] }
dashmap = { version = "6.1.0", features = ["serde"] }
twox-hash = "2.1.0"
dirs = "6.0.0"
katex = { version = "0.4.6", default-features = false, features = ["duktape"] }

[dev-dependencies]
templates = { path = "../templates" }
insta = "1.12.0"
8 changes: 6 additions & 2 deletions components/markdown/benches/all.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
#![feature(test)]
extern crate test;

use std::collections::HashMap;
use std::{collections::HashMap, sync::Arc};

use config::Config;
use libs::tera::Tera;
use markdown::{render_content, RenderContext};
use markdown::{context::Caches, render_content, RenderContext};
use utils::types::InsertAnchor;

const CONTENT: &str = r#"
Expand Down Expand Up @@ -95,6 +95,7 @@ fn bench_render_content_with_highlighting(b: &mut test::Bencher) {
current_page_permalink,
&permalinks_ctx,
InsertAnchor::None,
Arc::new(Caches::default()),
);
let shortcode_def = utils::templates::get_shortcodes(&tera);
context.set_shortcode_definitions(&shortcode_def);
Expand All @@ -116,6 +117,7 @@ fn bench_render_content_without_highlighting(b: &mut test::Bencher) {
current_page_permalink,
&permalinks_ctx,
InsertAnchor::None,
Arc::new(Caches::default()),
);
let shortcode_def = utils::templates::get_shortcodes(&tera);
context.set_shortcode_definitions(&shortcode_def);
Expand All @@ -137,6 +139,7 @@ fn bench_render_content_no_shortcode(b: &mut test::Bencher) {
current_page_permalink,
&permalinks_ctx,
InsertAnchor::None,
Arc::new(Caches::default()),
);

b.iter(|| render_content(&content2, &context).unwrap());
Expand All @@ -159,6 +162,7 @@ fn bench_render_content_with_emoji(b: &mut test::Bencher) {
current_page_permalink,
&permalinks_ctx,
InsertAnchor::None,
Arc::new(Caches::default()),
);

b.iter(|| render_content(&content2, &context).unwrap());
Expand Down
105 changes: 105 additions & 0 deletions components/markdown/src/cache.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
use bincode;
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions};
use std::hash::Hash;

use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};

/// Generic cache using DashMap, storing data in a binary file
#[derive(Debug)]
pub struct GenericCache<K, V>
where
K: Eq + Hash + Serialize + for<'de> Deserialize<'de>,
V: Serialize + for<'de> Deserialize<'de>,
{
cache_file: PathBuf,
cache: DashMap<K, V>,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm not totally sure whether it matters at the kind of scale/concurrency zola operates on, but you might find papaya a better fit for a concurrent hash map. this (biased) blogpost by the author totally convinced me.

}

impl<K, V> GenericCache<K, V>
where
K: Eq + Hash + Serialize + for<'de> Deserialize<'de>,
V: Serialize + for<'de> Deserialize<'de>,
{
/// Get the directory where the cache is stored
pub fn dir(&self) -> &Path {
self.cache_file.parent().unwrap()
}

/// Create a new cache for a specific type
pub fn new(base_cache_dir: &Path, filename: &str) -> crate::Result<Self> {
// Create the base cache directory
fs::create_dir_all(base_cache_dir)?;

// Full path to the cache file
let cache_file = base_cache_dir.join(filename);

// Attempt to load existing cache
let cache = match Self::read_cache(&cache_file) {
Ok(loaded_cache) => {
println!("Loaded cache from {:?} ({:?})", cache_file, loaded_cache.len());
loaded_cache
}
Err(e) => {
println!("Failed to load cache: {}", e);
DashMap::new()
}
};

Ok(Self { cache_file, cache })
}

/// Read cache from file
fn read_cache(cache_file: &Path) -> io::Result<DashMap<K, V>> {
if !cache_file.exists() {
return Ok(DashMap::new());
}

let mut file = File::open(cache_file)?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer)?;

bincode::deserialize(&buffer).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}

/// Write cache to file
pub fn write(&self) -> io::Result<()> {
let serialized = bincode::serialize(&self.cache)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;

let mut file =
OpenOptions::new().write(true).create(true).truncate(true).open(&self.cache_file)?;

file.write_all(&serialized)?;
Ok(())
}

/// Get a reference to the underlying DashMap
pub fn inner(&self) -> &DashMap<K, V> {
&self.cache
}

pub fn get(&self, key: &K) -> Option<V>
where
V: Clone,
{
self.cache.get(key).map(|r| r.value().clone())
}

pub fn insert(&self, key: K, value: V) {
self.cache.insert(key, value);
}

/// Clear the cache and remove the file
pub fn clear(&self) -> crate::Result<()> {
self.cache.clear();

if self.cache_file.exists() {
fs::remove_file(&self.cache_file)?;
}

Ok(())
}
}
Loading